[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; text-rendering: optimizeLegibility;
} }
.sunburst path { .sunburst path {
stroke: #333; stroke: #ddd;
stroke-width: 0.5px; stroke-width: 0.5px;
} }
.sunburst .center-label { .sunburst .center-label {
text-anchor: middle; text-anchor: middle;
fill: #000; fill: #333;
pointer-events: none; pointer-events: none;
} }
.sunburst .path-abs-percent { .sunburst .path-abs-percent {
font-size: 3.5em; font-size: 3em;
font-weight: 400; font-weight: 700;
} }
.sunburst .path-cond-percent { .sunburst .path-cond-percent {
font-size: 2em; font-size: 2em;
} }
.sunburst .path-metrics { .sunburst .path-metrics {
font-size: 1.5em; color: #777;
} }
.sunburst .path-ratio { .sunburst .path-ratio {
font-size: 1.2em; color: #777;
} }
.sunburst .breadcrumbs text { .sunburst .breadcrumbs text {
font-weight: 600; font-weight: 600;
font-size: 1.2em; font-size: 1.2em;
text-anchor: middle; text-anchor: middle;
fill: #000; fill: #333;
} }
/* dashboard specific */ /* dashboard specific */
@ -36,10 +36,11 @@
font-size: 1em; font-size: 1em;
} }
.dashboard .sunburst .path-abs-percent { .dashboard .sunburst .path-abs-percent {
font-size: 2.5em; font-size: 2em;
font-weight: 700;
} }
.dashboard .sunburst .path-cond-percent { .dashboard .sunburst .path-cond-percent {
font-size: 1.75em; font-size: 1.5em;
} }
.dashboard .sunburst .path-metrics { .dashboard .sunburst .path-metrics {
font-size: 1em; 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 d3 from 'd3';
import PropTypes from 'prop-types';
import { getColorFromScheme } from '../modules/colors'; import { getColorFromScheme } from '../modules/colors';
import { wrapSvgText } from '../modules/utils'; import { wrapSvgText } from '../modules/utils';
import './sunburst.css'; 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 // Modified from http://bl.ocks.org/kerryrodden/7090426
function sunburstVis(slice, payload) { function Sunburst(element, props) {
const container = d3.select(slice.selector); 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 // vars with shared scope within this function
const margin = { top: 10, right: 5, bottom: 10, left: 5 }; const margin = { top: 10, right: 5, bottom: 10, left: 5 };
const containerWidth = slice.width(); const containerWidth = width;
const containerHeight = slice.height(); const containerHeight = height;
const breadcrumbHeight = containerHeight * 0.085; const breadcrumbHeight = containerHeight * 0.085;
const visWidth = containerWidth - margin.left - margin.right; const visWidth = containerWidth - margin.left - margin.right;
const visHeight = containerHeight - margin.top - margin.bottom - breadcrumbHeight; const visHeight = containerHeight - margin.top - margin.bottom - breadcrumbHeight;
@ -36,12 +76,8 @@ function sunburstVis(slice, payload) {
const arc = d3.svg.arc() const arc = d3.svg.arc()
.startAngle(d => d.x) .startAngle(d => d.x)
.endAngle(d => d.x + d.dx) .endAngle(d => d.x + d.dx)
.innerRadius(function (d) { .innerRadius(d => Math.sqrt(d.y))
return Math.sqrt(d.y); .outerRadius(d => Math.sqrt(d.y + d.dy));
})
.outerRadius(function (d) {
return Math.sqrt(d.y + d.dy);
});
const formatNum = d3.format('.1s'); const formatNum = d3.format('.1s');
const formatPerc = d3.format('.1p'); const formatPerc = d3.format('.1p');
@ -52,8 +88,7 @@ function sunburstVis(slice, payload) {
.attr('width', containerWidth) .attr('width', containerWidth)
.attr('height', containerHeight); .attr('height', containerHeight);
function createBreadcrumbs(rawData) { function createBreadcrumbs(firstRowData) {
const firstRowData = rawData.data[0];
// -2 bc row contains 2x metrics, +extra for %label and buffer // -2 bc row contains 2x metrics, +extra for %label and buffer
maxBreadcrumbs = (firstRowData.length - 2) + 1; maxBreadcrumbs = (firstRowData.length - 2) + 1;
breadcrumbDims = { breadcrumbDims = {
@ -71,18 +106,6 @@ function sunburstVis(slice, payload) {
.attr('class', 'end-label'); .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. // Generate a string that describes the points of a breadcrumb polygon.
function breadcrumbPoints(d, i) { function breadcrumbPoints(d, i) {
const points = []; const points = [];
@ -100,9 +123,7 @@ function sunburstVis(slice, payload) {
function updateBreadcrumbs(sequenceArray, percentageString) { function updateBreadcrumbs(sequenceArray, percentageString) {
const g = breadcrumbs.selectAll('g') const g = breadcrumbs.selectAll('g')
.data(sequenceArray, function (d) { .data(sequenceArray, d => d.name + d.depth);
return d.name + d.depth;
});
// Add breadcrumb and label for entering nodes. // Add breadcrumb and label for entering nodes.
const entering = g.enter().append('svg:g'); const entering = g.enter().append('svg:g');
@ -111,7 +132,7 @@ function sunburstVis(slice, payload) {
.attr('points', breadcrumbPoints) .attr('points', breadcrumbPoints)
.style('fill', function (d) { .style('fill', function (d) {
return colorByCategory ? return colorByCategory ?
getColorFromScheme(d.name, slice.formData.color_scheme) : getColorFromScheme(d.name, colorScheme) :
colorScale(d.m2 / d.m1); colorScale(d.m2 / d.m1);
}); });
@ -122,7 +143,7 @@ function sunburstVis(slice, payload) {
.style('fill', function (d) { .style('fill', function (d) {
// Make text white or black based on the lightness of the background // Make text white or black based on the lightness of the background
const col = d3.hsl(colorByCategory ? const col = d3.hsl(colorByCategory ?
getColorFromScheme(d.name, slice.formData.color_scheme) : getColorFromScheme(d.name, colorScheme) :
colorScale(d.m2 / d.m1)); colorScale(d.m2 / d.m1));
return col.l < 0.5 ? 'white' : 'black'; return col.l < 0.5 ? 'white' : 'black';
}) })
@ -166,6 +187,7 @@ function sunburstVis(slice, payload) {
// If metrics match, assume we are coloring by category // If metrics match, assume we are coloring by category
const metricsMatch = Math.abs(d.m1 - d.m2) < 0.00001; const metricsMatch = Math.abs(d.m1 - d.m2) < 0.00001;
console.log('metrics', metrics);
gMiddleText.selectAll('*').remove(); gMiddleText.selectAll('*').remove();
@ -184,27 +206,24 @@ function sunburstVis(slice, payload) {
gMiddleText.append('text') gMiddleText.append('text')
.attr('class', 'path-metrics') .attr('class', 'path-metrics')
.attr('y', yOffsets[offsetIndex++]) .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') gMiddleText.append('text')
.attr('class', 'path-ratio') .attr('class', 'path-ratio')
.attr('y', yOffsets[offsetIndex++]) .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. // Reset and fade all the segments.
arcs.selectAll('path') arcs.selectAll('path')
.style('stroke-width', null) .style('stroke-width', null)
.style('stroke', null) .style('stroke', null)
.style('opacity', 0.7); .style('opacity', 0.3);
// Then highlight only those that are an ancestor of the current segment. // Then highlight only those that are an ancestor of the current segment.
arcs.selectAll('path') arcs.selectAll('path')
.filter(function (node) { .filter(node => (sequenceArray.indexOf(node) >= 0))
return (sequenceArray.indexOf(node) >= 0);
})
.style('opacity', 1) .style('opacity', 1)
.style('stroke-width', '2px') .style('stroke', '#aaa');
.style('stroke', '#000');
updateBreadcrumbs(sequenceArray, absolutePercString); updateBreadcrumbs(sequenceArray, absolutePercString);
} }
@ -244,7 +263,7 @@ function sunburstVis(slice, payload) {
const m1 = Number(row[row.length - 2]); const m1 = Number(row[row.length - 2]);
const m2 = Number(row[row.length - 1]); const m2 = Number(row[row.length - 1]);
const levels = row.slice(0, row.length - 2); 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; continue;
} }
let currentNode = root; let currentNode = root;
@ -263,8 +282,7 @@ function sunburstVis(slice, payload) {
currChild = children[k]; currChild = children[k];
if (currChild.name === nodeName && if (currChild.name === nodeName &&
currChild.level === level) { currChild.level === level) {
// must match name AND level // must match name AND level
childNode = currChild; childNode = currChild;
foundChild = true; foundChild = true;
break; break;
@ -313,8 +331,8 @@ function sunburstVis(slice, payload) {
} }
// Main function to draw and set up the visualization, once we have the data. // Main function to draw and set up the visualization, once we have the data.
function createVisualization(rawData) { function createVisualization(rows) {
const tree = buildHierarchy(rawData.data); const root = buildHierarchy(rows);
vis = svg.append('svg:g') vis = svg.append('svg:g')
.attr('class', 'sunburst-vis') .attr('class', 'sunburst-vis')
@ -339,15 +357,12 @@ function sunburstVis(slice, payload) {
.style('opacity', 0); .style('opacity', 0);
// For efficiency, filter nodes to keep only those large enough to see. // For efficiency, filter nodes to keep only those large enough to see.
const nodes = partition.nodes(tree) const nodes = partition.nodes(root)
.filter(function (d) { .filter(d => d.dx > 0.005); // 0.005 radians = 0.29 degrees
return (d.dx > 0.005); // 0.005 radians = 0.29 degrees
});
let ext; let ext;
const fd = slice.formData;
if (fd.metric !== fd.secondary_metric && fd.secondary_metric) { if (metrics[0] !== metrics[1] && metrics[1]) {
colorByCategory = false; colorByCategory = false;
ext = d3.extent(nodes, d => d.m2 / d.m1); ext = d3.extent(nodes, d => d.m2 / d.m1);
colorScale = d3.scale.linear() colorScale = d3.scale.linear()
@ -355,26 +370,40 @@ function sunburstVis(slice, payload) {
.range(['#00D1C1', 'white', '#FFB400']); .range(['#00D1C1', 'white', '#FFB400']);
} }
const path = arcs.data([tree]).selectAll('path') arcs.selectAll('path')
.data(nodes) .data(nodes)
.enter() .enter()
.append('svg:path') .append('svg:path')
.attr('display', function (d) { .attr('display', d => d.depth ? null : 'none')
return d.depth ? null : 'none'; .attr('d', arc)
}) .attr('fill-rule', 'evenodd')
.attr('d', arc) .style('fill', d => colorByCategory
.attr('fill-rule', 'evenodd') ? getColorFromScheme(d.name, colorScheme)
.style('fill', d => colorByCategory ? : colorScale(d.m2 / d.m1))
getColorFromScheme(d.name, fd.color_scheme) : .style('opacity', 1)
colorScale(d.m2 / d.m1)) .on('mouseenter', mouseenter);
.style('opacity', 1)
.on('mouseenter', mouseenter);
// Get total size of the tree = value of root node from partition. // Get total size of the tree = value of root node from partition.
totalSize = path.node().__data__.value; totalSize = root.value;
} }
createBreadcrumbs(payload); createBreadcrumbs(data[0]);
createVisualization(payload); 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;