blob: e5be0f00309d9063c71b59f406437254485857ae [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable no-param-reassign, react/sort-prop-types */
import d3 from 'd3';
import PropTypes from 'prop-types';
import {
getNumberFormatter,
NumberFormats,
CategoricalColorNamespace,
getSequentialSchemeRegistry,
} from '@superset-ui/core';
import wrapSvgText from './utils/wrapSvgText';
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,
linearColorScheme: PropTypes.string,
numberFormat: 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;
}
function buildHierarchy(rows) {
const root = {
name: 'root',
children: [],
};
// each record [groupby1val, groupby2val, (<string> or 0)n, m1, m2]
rows.forEach(row => {
const m1 = Number(row[row.length - 2]);
const m2 = Number(row[row.length - 1]);
const levels = row.slice(0, -2);
if (Number.isNaN(m1)) {
// e.g. if this is a header row
return;
}
let currentNode = root;
for (let level = 0; level < levels.length; level += 1) {
const children = currentNode.children || [];
const nodeName = levels[level].toString();
// If the next node has the name '0', it will
const isLeafNode = level >= levels.length - 1 || levels[level + 1] === 0;
let childNode;
if (!isLeafNode) {
childNode = children.find(child => child.name === nodeName && child.level === level);
if (!childNode) {
childNode = {
name: nodeName,
children: [],
level,
};
children.push(childNode);
}
currentNode = childNode;
} else if (nodeName !== 0) {
// Reached the end of the sequence; create a leaf node.
childNode = {
name: nodeName,
m1,
m2,
};
children.push(childNode);
}
}
});
function recurse(node) {
if (node.children) {
let sums;
let m1 = 0;
let m2 = 0;
for (let i = 0; i < node.children.length; i += 1) {
sums = recurse(node.children[i]);
m1 += sums[0];
m2 += sums[1];
}
node.m1 = m1;
node.m2 = m2;
}
return [node.m1, node.m2];
}
recurse(root);
return root;
}
// Modified from http://bl.ocks.org/kerryrodden/7090426
function Sunburst(element, props) {
const container = d3.select(element);
container.classed('superset-legacy-chart-sunburst', true);
const { data, width, height, colorScheme, linearColorScheme, metrics, numberFormat } = props;
// vars with shared scope within this function
const margin = { top: 10, right: 5, bottom: 10, left: 5 };
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;
const radius = Math.min(visWidth, visHeight) / 2;
let colorByCategory = true; // color by category if primary/secondary metrics match
let maxBreadcrumbs;
let breadcrumbDims; // set based on data
let totalSize; // total size of all segments; set after loading the data.
let breadcrumbs;
let vis;
let arcs;
let gMiddleText; // dom handles
const categoricalColorScale = CategoricalColorNamespace.getScale(colorScheme);
let linearColorScale;
// Helper + path gen functions
const partition = d3.layout
.partition()
.size([2 * Math.PI, radius * radius])
.value(d => d.m1);
const arc = d3.svg
.arc()
.startAngle(d => d.x)
.endAngle(d => d.x + d.dx)
.innerRadius(d => Math.sqrt(d.y))
.outerRadius(d => Math.sqrt(d.y + d.dy));
const formatNum = getNumberFormatter(numberFormat || NumberFormats.SI_3_DIGIT);
const formatPerc = getNumberFormatter(NumberFormats.PERCENT_3_POINT);
container.select('svg').remove();
const svg = container
.append('svg:svg')
.attr('width', containerWidth)
.attr('height', containerHeight);
function createBreadcrumbs(firstRowData) {
// -2 bc row contains 2x metrics, +extra for %label and buffer
maxBreadcrumbs = firstRowData.length - 2 + 1;
breadcrumbDims = {
width: visWidth / maxBreadcrumbs,
height: breadcrumbHeight * 0.8, // more margin
spacing: 3,
tipTailWidth: 10,
};
breadcrumbs = svg
.append('svg:g')
.attr('class', 'breadcrumbs')
.attr('transform', `translate(${margin.left},${margin.top})`);
breadcrumbs.append('svg:text').attr('class', 'end-label');
}
// Generate a string that describes the points of a breadcrumb polygon.
function breadcrumbPoints(d, i) {
const points = [];
points.push('0,0');
points.push(`${breadcrumbDims.width},0`);
points.push(
`${breadcrumbDims.width + breadcrumbDims.tipTailWidth},${breadcrumbDims.height / 2}`,
);
points.push(`${breadcrumbDims.width},${breadcrumbDims.height}`);
points.push(`0,${breadcrumbDims.height}`);
if (i > 0) {
// Leftmost breadcrumb; don't include 6th vertex.
points.push(`${breadcrumbDims.tipTailWidth},${breadcrumbDims.height / 2}`);
}
return points.join(' ');
}
function updateBreadcrumbs(sequenceArray, percentageString) {
const g = breadcrumbs.selectAll('g').data(sequenceArray, d => d.name + d.depth);
// Add breadcrumb and label for entering nodes.
const entering = g.enter().append('svg:g');
entering
.append('svg:polygon')
.attr('points', breadcrumbPoints)
.style('fill', d =>
colorByCategory ? categoricalColorScale(d.name) : linearColorScale(d.m2 / d.m1),
);
entering
.append('svg:text')
.attr('x', (breadcrumbDims.width + breadcrumbDims.tipTailWidth) / 2)
.attr('y', breadcrumbDims.height / 4)
.attr('dy', '0.85em')
.style('fill', d => {
// Make text white or black based on the lightness of the background
const col = d3.hsl(
colorByCategory ? categoricalColorScale(d.name) : linearColorScale(d.m2 / d.m1),
);
return col.l < 0.5 ? 'white' : 'black';
})
.attr('class', 'step-label')
.text(d => d.name.replace(/_/g, ' '))
.call(wrapSvgText, breadcrumbDims.width, breadcrumbDims.height / 2);
// Set position for entering and updating nodes.
g.attr(
'transform',
(d, i) => `translate(${i * (breadcrumbDims.width + breadcrumbDims.spacing)}, 0)`,
);
// Remove exiting nodes.
g.exit().remove();
// Now move and update the percentage at the end.
breadcrumbs
.select('.end-label')
.attr('x', (sequenceArray.length + 0.5) * (breadcrumbDims.width + breadcrumbDims.spacing))
.attr('y', breadcrumbDims.height / 2)
.attr('dy', '0.35em')
.text(percentageString);
// Make the breadcrumb trail visible, if it's hidden.
breadcrumbs.style('visibility', null);
}
// Fade all but the current sequence, and show it in the breadcrumb trail.
function mouseenter(d) {
const sequenceArray = getAncestors(d);
const parentOfD = sequenceArray[sequenceArray.length - 2] || null;
const absolutePercentage = (d.m1 / totalSize).toPrecision(3);
const conditionalPercentage = parentOfD ? (d.m1 / parentOfD.m1).toPrecision(3) : null;
const absolutePercString = formatPerc(absolutePercentage);
const conditionalPercString = parentOfD ? formatPerc(conditionalPercentage) : '';
// 3 levels of text if inner-most level, 4 otherwise
const yOffsets = ['-25', '7', '35', '60'];
let offsetIndex = 0;
// If metrics match, assume we are coloring by category
const metricsMatch = Math.abs(d.m1 - d.m2) < 0.00001;
gMiddleText.selectAll('*').remove();
offsetIndex += 1;
gMiddleText
.append('text')
.attr('class', 'path-abs-percent')
.attr('y', yOffsets[offsetIndex])
.text(`${absolutePercString} of total`);
if (conditionalPercString) {
offsetIndex += 1;
gMiddleText
.append('text')
.attr('class', 'path-cond-percent')
.attr('y', yOffsets[offsetIndex])
.text(`${conditionalPercString} of parent`);
}
offsetIndex += 1;
gMiddleText
.append('text')
.attr('class', 'path-metrics')
.attr('y', yOffsets[offsetIndex])
.text(
`${metricLabel(metrics[0])}: ${formatNum(d.m1)}${
metricsMatch ? '' : `, ${metricLabel(metrics[1])}: ${formatNum(d.m2)}`
}`,
);
offsetIndex += 1;
gMiddleText
.append('text')
.attr('class', 'path-ratio')
.attr('y', yOffsets[offsetIndex])
.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.3);
// Then highlight only those that are an ancestor of the current segment.
arcs
.selectAll('path')
.filter(node => sequenceArray.includes(node))
.style('opacity', 1)
.style('stroke', '#aaa');
updateBreadcrumbs(sequenceArray, absolutePercString);
}
// Restore everything to full opacity when moving off the visualization.
function mouseleave() {
// Hide the breadcrumb trail
breadcrumbs.style('visibility', 'hidden');
gMiddleText.selectAll('*').remove();
// Deactivate all segments during transition.
arcs.selectAll('path').on('mouseenter', null);
// Transition each segment to full opacity and then reactivate it.
arcs
.selectAll('path')
.transition()
.duration(200)
.style('opacity', 1)
.style('stroke', null)
.style('stroke-width', null)
.each('end', function end() {
d3.select(this).on('mouseenter', mouseenter);
});
}
// Main function to draw and set up the visualization, once we have the data.
function createVisualization(rows) {
const root = buildHierarchy(rows);
vis = svg
.append('svg:g')
.attr('class', 'sunburst-vis')
.attr(
'transform',
'translate(' +
`${margin.left + visWidth / 2},` +
`${margin.top + breadcrumbHeight + visHeight / 2}` +
')',
)
.on('mouseleave', mouseleave);
arcs = vis.append('svg:g').attr('id', 'arcs');
gMiddleText = vis.append('svg:g').attr('class', 'center-label');
// Bounding circle underneath the sunburst, to make it easier to detect
// when the mouse leaves the parent g.
arcs.append('svg:circle').attr('r', radius).style('opacity', 0);
// For efficiency, filter nodes to keep only those large enough to see.
const nodes = partition.nodes(root).filter(d => d.dx > 0.005); // 0.005 radians = 0.29 degrees
if (metrics[0] !== metrics[1] && metrics[1]) {
colorByCategory = false;
const ext = d3.extent(nodes, d => d.m2 / d.m1);
linearColorScale = getSequentialSchemeRegistry()
.get(linearColorScheme)
.createLinearScale(ext);
}
arcs
.selectAll('path')
.data(nodes)
.enter()
.append('svg:path')
.attr('display', d => (d.depth ? null : 'none'))
.attr('d', arc)
.attr('fill-rule', 'evenodd')
.style('fill', d =>
colorByCategory ? categoricalColorScale(d.name) : linearColorScale(d.m2 / d.m1),
)
.style('opacity', 1)
.on('mouseenter', mouseenter);
// Get total size of the tree = value of root node from partition.
totalSize = root.value;
}
createBreadcrumbs(data[0]);
createVisualization(data);
}
Sunburst.displayName = 'Sunburst';
Sunburst.propTypes = propTypes;
export default Sunburst;