[SIP-5] Refactor sunburst (#5699)

* extract slice and formData

* fix small issues

* Update visual

* update css
This commit is contained in:
Krist Wongsuphasawat 2018-08-27 10:43:04 -07:00 committed by Chris Williams
parent d7f06cbc26
commit 8d01c84c54
2 changed files with 106 additions and 76 deletions

View File

@ -2,33 +2,33 @@
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 */
@ -36,10 +36,11 @@
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;

View File

@ -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;
@ -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');
@ -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 = {
@ -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 = [];
@ -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');
@ -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);
});
@ -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';
})
@ -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();
@ -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);
}
@ -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;
@ -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;
@ -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')
@ -339,15 +357,12 @@ 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()
@ -355,26 +370,40 @@ function sunburstVis(slice, payload) {
.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);
}
module.exports = sunburstVis;
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],
});
}
export default adaptor;