Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SIP-5] Refactor sunburst #5699

Merged
merged 4 commits into from
Aug 27, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions superset/assets/src/visualizations/sunburst.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,45 @@
text-rendering: optimizeLegibility;
}
.sunburst path {
stroke: #333;
stroke: #ddd;
stroke-width: 0.5px;
}
.sunburst .center-label {
text-anchor: middle;
fill: #000;
fill: #333;
pointer-events: none;
}
.sunburst .path-abs-percent {
font-size: 3.5em;
font-weight: 400;
font-size: 3em;
font-weight: 700;
}
.sunburst .path-cond-percent {
font-size: 2em;
}
.sunburst .path-metrics {
font-size: 1.5em;
color: #777;
}
.sunburst .path-ratio {
font-size: 1.2em;
color: #777;
}

.sunburst .breadcrumbs text {
font-weight: 600;
font-size: 1.2em;
text-anchor: middle;
fill: #000;
fill: #333;
}

/* dashboard specific */
.dashboard .sunburst text {
font-size: 1em;
}
.dashboard .sunburst .path-abs-percent {
font-size: 2.5em;
font-size: 2em;
font-weight: 700;
}
.dashboard .sunburst .path-cond-percent {
font-size: 1.75em;
font-size: 1.5em;
}
.dashboard .sunburst .path-metrics {
font-size: 1em;
Expand Down
163 changes: 96 additions & 67 deletions superset/assets/src/visualizations/sunburst.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,58 @@
/* eslint-disable no-underscore-dangle, no-param-reassign */
/* eslint-disable no-param-reassign */
import d3 from 'd3';
import PropTypes from 'prop-types';
import { getColorFromScheme } from '../modules/colors';
import { wrapSvgText } from '../modules/utils';

import './sunburst.css';

const propTypes = {
// Each row is an array of [hierarchy-lvl1, hierarchy-lvl2, metric1, metric2]
// hierarchy-lvls are string. metrics are number
data: PropTypes.arrayOf(PropTypes.array),
width: PropTypes.number,
height: PropTypes.number,
colorScheme: PropTypes.string,
metrics: PropTypes.arrayOf(PropTypes.oneOfType([
PropTypes.string,
PropTypes.object, // The metric object
])),
};

function metricLabel(metric) {
return ((typeof metric) === 'string' || metric instanceof String)
? metric
: metric.label;
}

// Given a node in a partition layout, return an array of all of its ancestor
// nodes, highest first, but excluding the root.
function getAncestors(node) {
const path = [];
let current = node;
while (current.parent) {
path.unshift(current);
current = current.parent;
}
return path;
}

// Modified from http://bl.ocks.org/kerryrodden/7090426
function sunburstVis(slice, payload) {
const container = d3.select(slice.selector);
function Sunburst(element, props) {
PropTypes.checkPropTypes(propTypes, props, 'prop', 'Sunburst');

const container = d3.select(element);
const {
data,
width,
height,
colorScheme,
metrics,
} = props;

// vars with shared scope within this function
const margin = { top: 10, right: 5, bottom: 10, left: 5 };
const containerWidth = slice.width();
const containerHeight = slice.height();
const containerWidth = width;
const containerHeight = height;
const breadcrumbHeight = containerHeight * 0.085;
const visWidth = containerWidth - margin.left - margin.right;
const visHeight = containerHeight - margin.top - margin.bottom - breadcrumbHeight;
Expand All @@ -36,12 +76,8 @@ function sunburstVis(slice, payload) {
const arc = d3.svg.arc()
.startAngle(d => d.x)
.endAngle(d => d.x + d.dx)
.innerRadius(function (d) {
return Math.sqrt(d.y);
})
.outerRadius(function (d) {
return Math.sqrt(d.y + d.dy);
});
.innerRadius(d => Math.sqrt(d.y))
.outerRadius(d => Math.sqrt(d.y + d.dy));

const formatNum = d3.format('.1s');
const formatPerc = d3.format('.1p');
Expand All @@ -52,8 +88,7 @@ function sunburstVis(slice, payload) {
.attr('width', containerWidth)
.attr('height', containerHeight);

function createBreadcrumbs(rawData) {
const firstRowData = rawData.data[0];
function createBreadcrumbs(firstRowData) {
// -2 bc row contains 2x metrics, +extra for %label and buffer
maxBreadcrumbs = (firstRowData.length - 2) + 1;
breadcrumbDims = {
Expand All @@ -71,18 +106,6 @@ function sunburstVis(slice, payload) {
.attr('class', 'end-label');
}

// Given a node in a partition layout, return an array of all of its ancestor
// nodes, highest first, but excluding the root.
function getAncestors(node) {
const path = [];
let current = node;
while (current.parent) {
path.unshift(current);
current = current.parent;
}
return path;
}

// Generate a string that describes the points of a breadcrumb polygon.
function breadcrumbPoints(d, i) {
const points = [];
Expand All @@ -100,9 +123,7 @@ function sunburstVis(slice, payload) {

function updateBreadcrumbs(sequenceArray, percentageString) {
const g = breadcrumbs.selectAll('g')
.data(sequenceArray, function (d) {
return d.name + d.depth;
});
.data(sequenceArray, d => d.name + d.depth);

// Add breadcrumb and label for entering nodes.
const entering = g.enter().append('svg:g');
Expand All @@ -111,7 +132,7 @@ function sunburstVis(slice, payload) {
.attr('points', breadcrumbPoints)
.style('fill', function (d) {
return colorByCategory ?
getColorFromScheme(d.name, slice.formData.color_scheme) :
getColorFromScheme(d.name, colorScheme) :
colorScale(d.m2 / d.m1);
});

Expand All @@ -122,7 +143,7 @@ function sunburstVis(slice, payload) {
.style('fill', function (d) {
// Make text white or black based on the lightness of the background
const col = d3.hsl(colorByCategory ?
getColorFromScheme(d.name, slice.formData.color_scheme) :
getColorFromScheme(d.name, colorScheme) :
colorScale(d.m2 / d.m1));
return col.l < 0.5 ? 'white' : 'black';
})
Expand Down Expand Up @@ -166,6 +187,7 @@ function sunburstVis(slice, payload) {

// If metrics match, assume we are coloring by category
const metricsMatch = Math.abs(d.m1 - d.m2) < 0.00001;
console.log('metrics', metrics);

gMiddleText.selectAll('*').remove();

Expand All @@ -184,27 +206,24 @@ function sunburstVis(slice, payload) {
gMiddleText.append('text')
.attr('class', 'path-metrics')
.attr('y', yOffsets[offsetIndex++])
.text('m1: ' + formatNum(d.m1) + (metricsMatch ? '' : ', m2: ' + formatNum(d.m2)));
.text(`${metricLabel(metrics[0])}: ${formatNum(d.m1)}` + (metricsMatch ? '' : `, ${metricLabel(metrics[1])}: ${formatNum(d.m2)}`));

gMiddleText.append('text')
.attr('class', 'path-ratio')
.attr('y', yOffsets[offsetIndex++])
.text((metricsMatch ? '' : ('m2/m1: ' + formatPerc(d.m2 / d.m1))));
.text((metricsMatch ? '' : (`${metricLabel(metrics[1])}/${metricLabel(metrics[0])}: ${formatPerc(d.m2 / d.m1)}`)));

// Reset and fade all the segments.
arcs.selectAll('path')
.style('stroke-width', null)
.style('stroke', null)
.style('opacity', 0.7);
.style('opacity', 0.3);

// Then highlight only those that are an ancestor of the current segment.
arcs.selectAll('path')
.filter(function (node) {
return (sequenceArray.indexOf(node) >= 0);
})
.filter(node => (sequenceArray.indexOf(node) >= 0))
.style('opacity', 1)
.style('stroke-width', '2px')
.style('stroke', '#000');
.style('stroke', '#aaa');

updateBreadcrumbs(sequenceArray, absolutePercString);
}
Expand Down Expand Up @@ -244,7 +263,7 @@ function sunburstVis(slice, payload) {
const m1 = Number(row[row.length - 2]);
const m2 = Number(row[row.length - 1]);
const levels = row.slice(0, row.length - 2);
if (isNaN(m1)) { // e.g. if this is a header row
if (Number.isNaN(m1)) { // e.g. if this is a header row
continue;
}
let currentNode = root;
Expand All @@ -263,8 +282,7 @@ function sunburstVis(slice, payload) {
currChild = children[k];
if (currChild.name === nodeName &&
currChild.level === level) {
// must match name AND level

// must match name AND level
childNode = currChild;
foundChild = true;
break;
Expand Down Expand Up @@ -313,8 +331,8 @@ function sunburstVis(slice, payload) {
}

// Main function to draw and set up the visualization, once we have the data.
function createVisualization(rawData) {
const tree = buildHierarchy(rawData.data);
function createVisualization(rows) {
const root = buildHierarchy(rows);

vis = svg.append('svg:g')
.attr('class', 'sunburst-vis')
Expand All @@ -339,42 +357,53 @@ function sunburstVis(slice, payload) {
.style('opacity', 0);

// For efficiency, filter nodes to keep only those large enough to see.
const nodes = partition.nodes(tree)
.filter(function (d) {
return (d.dx > 0.005); // 0.005 radians = 0.29 degrees
});
const nodes = partition.nodes(root)
.filter(d => d.dx > 0.005); // 0.005 radians = 0.29 degrees

let ext;
const fd = slice.formData;

if (fd.metric !== fd.secondary_metric && fd.secondary_metric) {
if (metrics[0] !== metrics[1] && metrics[1]) {
colorByCategory = false;
ext = d3.extent(nodes, d => d.m2 / d.m1);
colorScale = d3.scale.linear()
.domain([ext[0], ext[0] + ((ext[1] - ext[0]) / 2), ext[1]])
.range(['#00D1C1', 'white', '#FFB400']);
}

const path = arcs.data([tree]).selectAll('path')
.data(nodes)
arcs.selectAll('path')
.data(nodes)
.enter()
.append('svg:path')
.attr('display', function (d) {
return d.depth ? null : 'none';
})
.attr('d', arc)
.attr('fill-rule', 'evenodd')
.style('fill', d => colorByCategory ?
getColorFromScheme(d.name, fd.color_scheme) :
colorScale(d.m2 / d.m1))
.style('opacity', 1)
.on('mouseenter', mouseenter);
.append('svg:path')
.attr('display', d => d.depth ? null : 'none')
.attr('d', arc)
.attr('fill-rule', 'evenodd')
.style('fill', d => colorByCategory
? getColorFromScheme(d.name, colorScheme)
: colorScale(d.m2 / d.m1))
.style('opacity', 1)
.on('mouseenter', mouseenter);

// Get total size of the tree = value of root node from partition.
totalSize = path.node().__data__.value;
totalSize = root.value;
}
createBreadcrumbs(payload);
createVisualization(payload);
createBreadcrumbs(data[0]);
createVisualization(data);
}

Sunburst.propTypes = propTypes;

function adaptor(slice, payload) {
const { selector, formData } = slice;
const { color_scheme: colorScheme, metric, secondary_metric: secondaryMetric } = formData;
const element = document.querySelector(selector);

return Sunburst(element, {
data: payload.data,
width: slice.width(),
height: slice.height(),
colorScheme,
metrics: [metric, secondaryMetric],
});
}

module.exports = sunburstVis;
export default adaptor;