Add rose, sankey, sunburst.

This commit is contained in:
Krist Wongsuphasawat 2019-01-31 11:26:09 -08:00 committed by Yongjie Zhao
parent 9f91454988
commit 7586951f16
29 changed files with 2002 additions and 1 deletions

View File

@ -0,0 +1,34 @@
## @superset-ui/legacy-plugin-chart-rose
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-rose.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-rose.svg?style=flat-square)
[![David (path)](https://img.shields.io/david/apache-superset/superset-ui.svg?path=packages%2Fsuperset-ui-legacy-plugin-chart-rose&style=flat-square)](https://david-dm.org/apache-superset/superset-ui?path=packages/superset-ui-legacy-plugin-chart-rose)
This plugin provides Nightingale Rose Diagram for Superset.
### Usage
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
```js
import RoseChartPlugin from '@superset-ui/legacy-plugin-chart-rose';
new RoseChartPlugin()
.configure({ key: 'rose' })
.register();
```
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-legacy/?selectedKind=plugin-chart-rose) for more details.
```js
<SuperChart
chartType="rose"
chartProps={{
width: 600,
height: 600,
formData: {...},
payload: {
data: {...},
},
}}
/>
```

View File

@ -0,0 +1,48 @@
{
"name": "@superset-ui/legacy-plugin-chart-rose",
"version": "0.0.0",
"description": "Superset Legacy Chart - Nightingale Rose Diagram",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"repository": {
"type": "git",
"url": "git+https://github.com/apache-superset/superset-ui-legacy.git"
},
"keywords": [
"superset"
],
"author": "Superset",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/apache-superset/superset-ui-legacy/issues"
},
"homepage": "https://github.com/apache-superset/superset-ui-legacy#readme",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@superset-ui/core": "^0.9.x",
"d3": "^3.5.17",
"nvd3": "1.8.6",
"prop-types": "^15.6.2"
},
"devDependencies": {
"@superset-ui/chart": "^0.9.x",
"@superset-ui/color": "^0.9.x",
"@superset-ui/number-format": "^0.9.x",
"@superset-ui/time-format": "^0.9.x",
"@superset-ui/translation": "^0.9.x"
},
"peerDependencies": {
"@superset-ui/chart": "^0.9.x",
"@superset-ui/color": "^0.9.x",
"@superset-ui/number-format": "^0.9.x",
"@superset-ui/time-format": "^0.9.x",
"@superset-ui/translation": "^0.9.x"
}
}

View File

@ -0,0 +1,22 @@
/**
* 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.
*/
import { reactify } from '@superset-ui/chart';
import Component from './Rose';
export default reactify(Component);

View File

@ -0,0 +1,42 @@
/**
* 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.
*/
.superset-legacy-chart-rose path {
transition: fill-opacity 180ms linear;
stroke: #fff;
stroke-width: 1px;
stroke-opacity: 1;
fill-opacity: 0.75;
}
.superset-legacy-chart-rose text {
font: 400 12px Arial, sans-serif;
pointer-events: none;
}
.superset-legacy-chart-rose .clickable path {
cursor: pointer;
}
.superset-legacy-chart-rose .hover path {
fill-opacity: 1;
}
.nv-legend .nv-series {
cursor: pointer;
}

View File

@ -0,0 +1,621 @@
/**
* 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 no-use-before-define: ["error", { "functions": false }] */
/* eslint-disable sort-keys, no-magic-numbers, no-restricted-syntax, no-plusplus, babel/no-invalid-this */
import d3 from 'd3';
import PropTypes from 'prop-types';
import nv from 'nvd3';
import { CategoricalColorNamespace } from '@superset-ui/color';
import { getNumberFormatter } from '@superset-ui/number-format';
import { getTimeFormatter } from '@superset-ui/time-format';
import './Rose.css';
const propTypes = {
// Data is an object hashed by numeric value, perhaps timestamp
data: PropTypes.objectOf(
PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.arrayOf(PropTypes.string),
name: PropTypes.arrayOf(PropTypes.string),
time: PropTypes.number,
value: PropTypes.number,
}),
),
),
width: PropTypes.number,
height: PropTypes.number,
dateTimeFormat: PropTypes.string,
numberFormat: PropTypes.string,
useRichTooltip: PropTypes.bool,
useAreaProportions: PropTypes.bool,
};
function copyArc(d) {
return {
startAngle: d.startAngle,
endAngle: d.endAngle,
innerRadius: d.innerRadius,
outerRadius: d.outerRadius,
};
}
function sortValues(a, b) {
if (a.value === b.value) {
return a.name > b.name ? 1 : -1;
}
return b.value - a.value;
}
function Rose(element, props) {
const {
data,
width,
height,
colorScheme,
dateTimeFormat,
numberFormat,
useRichTooltip,
useAreaProportions,
} = props;
const div = d3.select(element);
div.classed('superset-legacy-chart-rose', true);
const datum = data;
const times = Object.keys(datum)
.map(t => parseInt(t, 10))
.sort((a, b) => a - b);
const numGrains = times.length;
const numGroups = datum[times[0]].length;
const format = getNumberFormatter(numberFormat);
const timeFormat = getTimeFormatter(dateTimeFormat);
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
d3.select('.nvtooltip').remove();
div.selectAll('*').remove();
const arc = d3.svg.arc();
const legend = nv.models.legend();
const tooltip = nv.models.tooltip();
const state = { disabled: datum[times[0]].map(() => false) };
const svg = div
.append('svg')
.attr('width', width)
.attr('height', height);
const g = svg
.append('g')
.attr('class', 'rose')
.append('g');
const legendWrap = g.append('g').attr('class', 'legendWrap');
function legendData(adatum) {
return adatum[times[0]].map((v, i) => ({
disabled: state.disabled[i],
key: v.name,
}));
}
function tooltipData(d, i, adatum) {
const timeIndex = Math.floor(d.arcId / numGroups);
const series = useRichTooltip
? adatum[times[timeIndex]]
.filter(v => !state.disabled[v.id % numGroups])
.map(v => ({
key: v.name,
value: v.value,
color: colorFn(v.name),
highlight: v.id === d.arcId,
}))
: [{ key: d.name, value: d.val, color: colorFn(d.name) }];
return {
key: 'Date',
value: d.time,
series,
};
}
legend.width(width).color(d => colorFn(d.key));
legendWrap.datum(legendData(datum)).call(legend);
tooltip.headerFormatter(timeFormat).valueFormatter(format);
// Compute max radius, which the largest value will occupy
const roseHeight = height - legend.height();
const margin = { top: legend.height() };
const edgeMargin = 35; // space between outermost radius and slice edge
const maxRadius = Math.min(width, roseHeight) / 2 - edgeMargin;
const labelThreshold = 0.05;
const gro = 8; // mouseover radius growth in pixels
const mini = 0.075;
const centerTranslate = `translate(${width / 2},${roseHeight / 2 + margin.top})`;
const roseWrap = g
.append('g')
.attr('transform', centerTranslate)
.attr('class', 'roseWrap');
const labelsWrap = g
.append('g')
.attr('transform', centerTranslate)
.attr('class', 'labelsWrap');
const groupLabelsWrap = g
.append('g')
.attr('transform', centerTranslate)
.attr('class', 'groupLabelsWrap');
// Compute inner and outer angles for each data point
function computeArcStates(adatum) {
// Find the max sum of values across all time
let maxSum = 0;
let grain = 0;
const sums = [];
for (const t of times) {
const sum = datum[t].reduce((a, v, i) => a + (state.disabled[i] ? 0 : v.value), 0);
maxSum = sum > maxSum ? sum : maxSum;
sums[grain] = sum;
grain++;
}
// Compute angle occupied by each time grain
const dtheta = (Math.PI * 2) / numGrains;
const angles = [];
for (let i = 0; i <= numGrains; i++) {
angles.push(dtheta * i - Math.PI / 2);
}
// Compute proportion
const P = maxRadius / maxSum;
const Q = P * maxRadius;
const computeOuterRadius = (value, innerRadius) =>
useAreaProportions
? Math.sqrt(Q * value + innerRadius * innerRadius)
: P * value + innerRadius;
const arcSt = {
data: [],
extend: {},
push: {},
pieStart: {},
pie: {},
pieOver: {},
mini: {},
labels: [],
groupLabels: [],
};
let arcId = 0;
for (let i = 0; i < numGrains; i++) {
const t = times[i];
const startAngle = angles[i];
const endAngle = angles[i + 1];
const G = (2 * Math.PI) / sums[i];
let innerRadius = 0;
let outerRadius;
let pieStartAngle = 0;
let pieEndAngle;
for (const v of adatum[t]) {
const val = state.disabled[arcId % numGroups] ? 0 : v.value;
const { name, time } = v;
v.id = arcId;
outerRadius = computeOuterRadius(val, innerRadius);
arcSt.data.push({ startAngle, endAngle, innerRadius, outerRadius, name, arcId, val, time });
arcSt.extend[arcId] = {
startAngle,
endAngle,
innerRadius,
name,
outerRadius: outerRadius + gro,
};
arcSt.push[arcId] = {
startAngle,
endAngle,
innerRadius: innerRadius + gro,
outerRadius: outerRadius + gro,
};
arcSt.pieStart[arcId] = {
startAngle,
endAngle,
innerRadius: mini * maxRadius,
outerRadius: maxRadius,
};
arcSt.mini[arcId] = {
startAngle,
endAngle,
innerRadius: innerRadius * mini,
outerRadius: outerRadius * mini,
};
arcId++;
innerRadius = outerRadius;
}
const labelArc = Object.assign({}, arcSt.data[i * numGroups]);
labelArc.outerRadius = maxRadius + 20;
labelArc.innerRadius = maxRadius + 15;
arcSt.labels.push(labelArc);
for (const v of adatum[t].concat().sort(sortValues)) {
const val = state.disabled[v.id % numGroups] ? 0 : v.value;
pieEndAngle = G * val + pieStartAngle;
arcSt.pie[v.id] = {
startAngle: pieStartAngle,
endAngle: pieEndAngle,
innerRadius: maxRadius * mini,
outerRadius: maxRadius,
percent: v.value / sums[i],
};
arcSt.pieOver[v.id] = {
startAngle: pieStartAngle,
endAngle: pieEndAngle,
innerRadius: maxRadius * mini,
outerRadius: maxRadius + gro,
};
pieStartAngle = pieEndAngle;
}
}
arcSt.groupLabels = arcSt.data.slice(0, numGroups);
return arcSt;
}
let arcSt = computeArcStates(datum);
function tween(target, resFunc) {
return function(d) {
const interpolate = d3.interpolate(copyArc(d), copyArc(target));
return t => resFunc(Object.assign(d, interpolate(t)));
};
}
function arcTween(target) {
return tween(target, d => arc(d));
}
function translateTween(target) {
return tween(target, d => `translate(${arc.centroid(d)})`);
}
// Grab the ID range of segments stand between
// this segment and the edge of the circle
const segmentsToEdgeCache = {};
function getSegmentsToEdge(arcId) {
if (segmentsToEdgeCache[arcId]) {
return segmentsToEdgeCache[arcId];
}
const timeIndex = Math.floor(arcId / numGroups);
segmentsToEdgeCache[arcId] = [arcId + 1, numGroups * (timeIndex + 1) - 1];
return segmentsToEdgeCache[arcId];
}
// Get the IDs of all segments in a timeIndex
const segmentsInTimeCache = {};
function getSegmentsInTime(arcId) {
if (segmentsInTimeCache[arcId]) {
return segmentsInTimeCache[arcId];
}
const timeIndex = Math.floor(arcId / numGroups);
segmentsInTimeCache[arcId] = [timeIndex * numGroups, (timeIndex + 1) * numGroups - 1];
return segmentsInTimeCache[arcId];
}
let clickId = -1;
let inTransition = false;
const ae = roseWrap
.selectAll('g')
.data(JSON.parse(JSON.stringify(arcSt.data))) // deep copy data state
.enter()
.append('g')
.attr('class', 'segment')
.classed('clickable', true)
.on('mouseover', mouseover)
.on('mouseout', mouseout)
.on('mousemove', mousemove)
.on('click', click);
const labels = labelsWrap
.selectAll('g')
.data(JSON.parse(JSON.stringify(arcSt.labels)))
.enter()
.append('g')
.attr('class', 'roseLabel')
.attr('transform', d => `translate(${arc.centroid(d)})`);
labels
.append('text')
.style('text-anchor', 'middle')
.style('fill', '#000')
.text(d => timeFormat(d.time));
const groupLabels = groupLabelsWrap
.selectAll('g')
.data(JSON.parse(JSON.stringify(arcSt.groupLabels)))
.enter()
.append('g');
groupLabels
.style('opacity', 0)
.attr('class', 'roseGroupLabels')
.append('text')
.style('text-anchor', 'middle')
.style('fill', '#000')
.text(d => d.name);
const arcs = ae
.append('path')
.attr('class', 'arc')
.attr('fill', d => colorFn(d.name))
.attr('d', arc);
function mousemove() {
tooltip();
}
function mouseover(b, i) {
tooltip.data(tooltipData(b, i, datum)).hidden(false);
const $this = d3.select(this);
$this.classed('hover', true);
if (clickId < 0 && !inTransition) {
$this
.select('path')
.interrupt()
.transition()
.duration(180)
.attrTween('d', arcTween(arcSt.extend[i]));
const edge = getSegmentsToEdge(i);
arcs
.filter(d => edge[0] <= d.arcId && d.arcId <= edge[1])
.interrupt()
.transition()
.duration(180)
.attrTween('d', d => arcTween(arcSt.push[d.arcId])(d));
} else if (!inTransition) {
const segments = getSegmentsInTime(clickId);
if (segments[0] <= b.arcId && b.arcId <= segments[1]) {
$this
.select('path')
.interrupt()
.transition()
.duration(180)
.attrTween('d', arcTween(arcSt.pieOver[i]));
}
}
}
function mouseout(b, i) {
tooltip.hidden(true);
const $this = d3.select(this);
$this.classed('hover', false);
if (clickId < 0 && !inTransition) {
$this
.select('path')
.interrupt()
.transition()
.duration(180)
.attrTween('d', arcTween(arcSt.data[i]));
const edge = getSegmentsToEdge(i);
arcs
.filter(d => edge[0] <= d.arcId && d.arcId <= edge[1])
.interrupt()
.transition()
.duration(180)
.attrTween('d', d => arcTween(arcSt.data[d.arcId])(d));
} else if (!inTransition) {
const segments = getSegmentsInTime(clickId);
if (segments[0] <= b.arcId && b.arcId <= segments[1]) {
$this
.select('path')
.interrupt()
.transition()
.duration(180)
.attrTween('d', arcTween(arcSt.pie[i]));
}
}
}
function click(b, i) {
if (inTransition) {
return;
}
const delay = d3.event.altKey ? 3750 : 375;
const segments = getSegmentsInTime(i);
if (clickId < 0) {
inTransition = true;
clickId = i;
labels
.interrupt()
.transition()
.duration(delay)
.attrTween('transform', d =>
translateTween({
outerRadius: 0,
innerRadius: 0,
startAngle: d.startAngle,
endAngle: d.endAngle,
})(d),
)
.style('opacity', 0);
groupLabels
.attr(
'transform',
`translate(${arc.centroid({
outerRadius: maxRadius + 20,
innerRadius: maxRadius + 15,
startAngle: arcSt.data[i].startAngle,
endAngle: arcSt.data[i].endAngle,
})})`,
)
.interrupt()
.transition()
.delay(delay)
.duration(delay)
.attrTween('transform', d =>
translateTween({
outerRadius: maxRadius + 20,
innerRadius: maxRadius + 15,
startAngle: arcSt.pie[segments[0] + d.arcId].startAngle,
endAngle: arcSt.pie[segments[0] + d.arcId].endAngle,
})(d),
)
.style('opacity', d =>
state.disabled[d.arcId] || arcSt.pie[segments[0] + d.arcId].percent < labelThreshold
? 0
: 1,
);
ae.classed('clickable', d => segments[0] > d.arcId || d.arcId > segments[1]);
arcs
.filter(d => segments[0] <= d.arcId && d.arcId <= segments[1])
.interrupt()
.transition()
.duration(delay)
.attrTween('d', d => arcTween(arcSt.pieStart[d.arcId])(d))
.transition()
.duration(delay)
.attrTween('d', d => arcTween(arcSt.pie[d.arcId])(d))
.each('end', () => {
inTransition = false;
});
arcs
.filter(d => segments[0] > d.arcId || d.arcId > segments[1])
.interrupt()
.transition()
.duration(delay)
.attrTween('d', d => arcTween(arcSt.mini[d.arcId])(d));
} else if (clickId < segments[0] || segments[1] < clickId) {
inTransition = true;
const clickSegments = getSegmentsInTime(clickId);
labels
.interrupt()
.transition()
.delay(delay)
.duration(delay)
.attrTween('transform', d => translateTween(arcSt.labels[d.arcId / numGroups])(d))
.style('opacity', 1);
groupLabels
.interrupt()
.transition()
.duration(delay)
.attrTween(
'transform',
translateTween({
outerRadius: maxRadius + 20,
innerRadius: maxRadius + 15,
startAngle: arcSt.data[clickId].startAngle,
endAngle: arcSt.data[clickId].endAngle,
}),
)
.style('opacity', 0);
ae.classed('clickable', true);
arcs
.filter(d => clickSegments[0] <= d.arcId && d.arcId <= clickSegments[1])
.interrupt()
.transition()
.duration(delay)
.attrTween('d', d => arcTween(arcSt.pieStart[d.arcId])(d))
.transition()
.duration(delay)
.attrTween('d', d => arcTween(arcSt.data[d.arcId])(d))
.each('end', () => {
clickId = -1;
inTransition = false;
});
arcs
.filter(d => clickSegments[0] > d.arcId || d.arcId > clickSegments[1])
.interrupt()
.transition()
.delay(delay)
.duration(delay)
.attrTween('d', d => arcTween(arcSt.data[d.arcId])(d));
}
}
function updateActive() {
const delay = d3.event.altKey ? 3000 : 300;
legendWrap.datum(legendData(datum)).call(legend);
const nArcSt = computeArcStates(datum);
inTransition = true;
if (clickId < 0) {
arcs
.style('opacity', 1)
.interrupt()
.transition()
.duration(delay)
.attrTween('d', d => arcTween(nArcSt.data[d.arcId])(d))
.each('end', () => {
inTransition = false;
arcSt = nArcSt;
})
.transition()
.duration(0)
.style('opacity', d => (state.disabled[d.arcId % numGroups] ? 0 : 1));
} else {
const segments = getSegmentsInTime(clickId);
arcs
.style('opacity', 1)
.interrupt()
.transition()
.duration(delay)
.attrTween('d', d =>
segments[0] <= d.arcId && d.arcId <= segments[1]
? arcTween(nArcSt.pie[d.arcId])(d)
: arcTween(nArcSt.mini[d.arcId])(d),
)
.each('end', () => {
inTransition = false;
arcSt = nArcSt;
})
.transition()
.duration(0)
.style('opacity', d => (state.disabled[d.arcId % numGroups] ? 0 : 1));
groupLabels
.interrupt()
.transition()
.duration(delay)
.attrTween('transform', d =>
translateTween({
outerRadius: maxRadius + 20,
innerRadius: maxRadius + 15,
startAngle: nArcSt.pie[segments[0] + d.arcId].startAngle,
endAngle: nArcSt.pie[segments[0] + d.arcId].endAngle,
})(d),
)
.style('opacity', d =>
state.disabled[d.arcId] || arcSt.pie[segments[0] + d.arcId].percent < labelThreshold
? 0
: 1,
);
}
}
legend.dispatch.on('stateChange', newState => {
if (state.disabled !== newState.disabled) {
state.disabled = newState.disabled;
updateActive();
}
});
}
Rose.displayName = 'Rose';
Rose.propTypes = propTypes;
export default Rose;

View File

@ -0,0 +1,39 @@
/**
* 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.
*/
import { t } from '@superset-ui/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/chart';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
description: '',
name: t('Nightingale Rose Chart'),
thumbnail,
useLegacyApi: true,
});
export default class RoseChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./ReactRose.js'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,34 @@
/**
* 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 sort-keys */
export default function transformProps(chartProps) {
const { width, height, formData, payload } = chartProps;
const { colorScheme, dateTimeFormat, numberFormat, richTooltip, roseAreaProportion } = formData;
return {
width,
height,
data: payload.data,
colorScheme,
dateTimeFormat,
numberFormat,
useAreaProportions: roseAreaProportion,
useRichTooltip: richTooltip,
};
}

View File

@ -0,0 +1,34 @@
## @superset-ui/legacy-plugin-chart-sankey
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-sankey.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-sankey.svg?style=flat-square)
[![David (path)](https://img.shields.io/david/apache-superset/superset-ui.svg?path=packages%2Fsuperset-ui-legacy-plugin-chart-sankey&style=flat-square)](https://david-dm.org/apache-superset/superset-ui?path=packages/superset-ui-legacy-plugin-chart-sankey)
This plugin provides Sankey Diagram for Superset.
### Usage
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
```js
import SankeyChartPlugin from '@superset-ui/legacy-plugin-chart-sankey';
new SankeyChartPlugin()
.configure({ key: 'sankey' })
.register();
```
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-legacy/?selectedKind=plugin-chart-sankey) for more details.
```js
<SuperChart
chartType="sankey"
chartProps={{
width: 600,
height: 600,
formData: {...},
payload: {
data: {...},
},
}}
/>
```

View File

@ -0,0 +1,46 @@
{
"name": "@superset-ui/legacy-plugin-chart-sankey",
"version": "0.0.0",
"description": "Superset Legacy Chart - Sankey Diagram",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"repository": {
"type": "git",
"url": "git+https://github.com/apache-superset/superset-ui-legacy.git"
},
"keywords": [
"superset"
],
"author": "Superset",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/apache-superset/superset-ui-legacy/issues"
},
"homepage": "https://github.com/apache-superset/superset-ui-legacy#readme",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@superset-ui/core": "^0.9.x",
"d3": "^3.5.17",
"d3-sankey": "^0.4.2",
"prop-types": "^15.6.2"
},
"devDependencies": {
"@superset-ui/chart": "^0.9.x",
"@superset-ui/color": "^0.9.x",
"@superset-ui/number-format": "^0.9.x",
"@superset-ui/translation": "^0.9.x"
},
"peerDependencies": {
"@superset-ui/chart": "^0.9.x",
"@superset-ui/color": "^0.9.x",
"@superset-ui/number-format": "^0.9.x",
"@superset-ui/translation": "^0.9.x"
}
}

View File

@ -0,0 +1,22 @@
/**
* 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.
*/
import { reactify } from '@superset-ui/chart';
import Component from './Sankey';
export default reactify(Component);

View File

@ -0,0 +1,51 @@
/**
* 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.
*/
.superset-legacy-chart-sankey .node rect {
cursor: move;
fill-opacity: .9;
shape-rendering: crispEdges;
}
.superset-legacy-chart-sankey .node text {
pointer-events: none;
text-shadow: 0 1px 0 #fff;
}
.superset-legacy-chart-sankey .link {
fill: none;
stroke: #000;
stroke-opacity: .2;
}
.superset-legacy-chart-sankey .link:hover {
stroke-opacity: .5;
}
.superset-legacy-chart-sankey-tooltip {
position: absolute;
width: auto;
background: #ddd;
padding: 10px;
font-size: 12px;
font-weight: 200;
color: #333;
border: 1px solid #fff;
text-align: center;
pointer-events: none;
}

View File

@ -0,0 +1,213 @@
/**
* 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, no-magic-numbers, sort-keys, babel/no-invalid-this */
import d3 from 'd3';
import PropTypes from 'prop-types';
import { sankey as d3Sankey } from 'd3-sankey';
import { CategoricalColorNamespace } from '@superset-ui/color';
import { getNumberFormatter, NumberFormats } from '@superset-ui/number-format';
import './Sankey.css';
const propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
source: PropTypes.string,
target: PropTypes.string,
value: PropTypes.number,
}),
),
width: PropTypes.number,
height: PropTypes.number,
colorScheme: PropTypes.string,
};
const formatNumber = getNumberFormatter(NumberFormats.FLOAT);
function Sankey(element, props) {
const { data, width, height, colorScheme } = props;
const div = d3.select(element);
div.classed('superset-legacy-chart-sankey', true);
const margin = {
top: 5,
right: 5,
bottom: 5,
left: 5,
};
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
div.selectAll('*').remove();
const svg = div
.append('svg')
.attr('width', innerWidth + margin.left + margin.right)
.attr('height', innerHeight + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const tooltip = div
.append('div')
.attr('class', 'sankey-tooltip')
.style('opacity', 0);
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const sankey = d3Sankey()
.nodeWidth(15)
.nodePadding(10)
.size([innerWidth, innerHeight]);
const path = sankey.link();
let nodes = {};
// Compute the distinct nodes from the links.
const links = data.map(row => {
const link = Object.assign({}, row);
link.source = nodes[link.source] || (nodes[link.source] = { name: link.source });
link.target = nodes[link.target] || (nodes[link.target] = { name: link.target });
link.value = Number(link.value);
return link;
});
nodes = d3.values(nodes);
sankey
.nodes(nodes)
.links(links)
.layout(32);
function getTooltipHtml(d) {
let html;
if (d.sourceLinks) {
// is node
html = `${d.name} Value: <span class='emph'>${formatNumber(d.value)}</span>`;
} else {
const val = formatNumber(d.value);
const sourcePercent = d3.round((d.value / d.source.value) * 100, 1);
const targetPercent = d3.round((d.value / d.target.value) * 100, 1);
html = [
"<div class=''>Path Value: <span class='emph'>",
val,
'</span></div>',
"<div class='percents'>",
"<span class='emph'>",
Number.isFinite(sourcePercent) ? sourcePercent : '100',
'%</span> of ',
d.source.name,
'<br/>',
`<span class='emph'>${Number.isFinite(targetPercent) ? targetPercent : '--'}%</span> of `,
d.target.name,
'target',
'</div>',
].join('');
}
return html;
}
function onmouseover(d) {
tooltip
.html(() => getTooltipHtml(d))
.transition()
.duration(200)
.style('left', `${d3.event.offsetX + 10}px`)
.style('top', `${d3.event.offsetY + 10}px`)
.style('opacity', 0.95);
}
function onmouseout() {
tooltip
.transition()
.duration(100)
.style('opacity', 0);
}
const link = svg
.append('g')
.selectAll('.link')
.data(links)
.enter()
.append('path')
.attr('class', 'link')
.attr('d', path)
.style('stroke-width', d => Math.max(1, d.dy))
.sort((a, b) => b.dy - a.dy)
.on('mouseover', onmouseover)
.on('mouseout', onmouseout);
function dragmove(d) {
d3.select(this).attr(
'transform',
`translate(${d.x},${(d.y = Math.max(0, Math.min(height - d.dy, d3.event.y)))})`,
);
sankey.relayout();
link.attr('d', path);
}
const node = svg
.append('g')
.selectAll('.node')
.data(nodes)
.enter()
.append('g')
.attr('class', 'node')
.attr('transform', d => `translate(${d.x},${d.y})`)
.call(
d3.behavior
.drag()
.origin(d => d)
.on('dragstart', function dragStart() {
this.parentNode.appendChild(this);
})
.on('drag', dragmove),
);
const minRectHeight = 5;
node
.append('rect')
.attr('height', d => (d.dy > minRectHeight ? d.dy : minRectHeight))
.attr('width', sankey.nodeWidth())
.style('fill', d => {
const name = d.name || 'N/A';
d.color = colorFn(name.replace(/ .*/, ''));
return d.color;
})
.style('stroke', d => d3.rgb(d.color).darker(2))
.on('mouseover', onmouseover)
.on('mouseout', onmouseout);
node
.append('text')
.attr('x', -6)
.attr('y', d => d.dy / 2)
.attr('dy', '.35em')
.attr('text-anchor', 'end')
.attr('transform', null)
.text(d => d.name)
.filter(d => d.x < innerWidth / 2)
.attr('x', 6 + sankey.nodeWidth())
.attr('text-anchor', 'start');
}
Sankey.displayName = 'Sankey';
Sankey.propTypes = propTypes;
export default Sankey;

View File

@ -0,0 +1,40 @@
/**
* 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.
*/
import { t } from '@superset-ui/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/chart';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
credits: ['https://github.com/d3/d3-sankey'],
description: '',
name: t('Sankey Diagram'),
thumbnail,
useLegacyApi: true,
});
export default class SankeyChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./ReactSankey.js'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,30 @@
/**
* 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 sort-keys */
export default function transformProps(chartProps) {
const { width, height, formData, payload } = chartProps;
const { colorScheme } = formData;
return {
width,
height,
data: payload.data,
colorScheme,
};
}

View File

@ -0,0 +1,34 @@
## @superset-ui/legacy-plugin-chart-sunburst
[![Version](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-sunburst.svg?style=flat-square)](https://img.shields.io/npm/v/@superset-ui/legacy-plugin-chart-sunburst.svg?style=flat-square)
[![David (path)](https://img.shields.io/david/apache-superset/superset-ui.svg?path=packages%2Fsuperset-ui-legacy-plugin-chart-sunburst&style=flat-square)](https://david-dm.org/apache-superset/superset-ui?path=packages/superset-ui-legacy-plugin-chart-sunburst)
This plugin provides Sunburst for Superset.
### Usage
Configure `key`, which can be any `string`, and register the plugin. This `key` will be used to lookup this chart throughout the app.
```js
import SunburstChartPlugin from '@superset-ui/legacy-plugin-chart-sunburst';
new SunburstChartPlugin()
.configure({ key: 'sunburst' })
.register();
```
Then use it via `SuperChart`. See [storybook](https://apache-superset.github.io/superset-ui-legacy/?selectedKind=plugin-chart-sunburst) for more details.
```js
<SuperChart
chartType="sunburst"
chartProps={{
width: 600,
height: 600,
formData: {...},
payload: {
data: {...},
},
}}
/>
```

View File

@ -0,0 +1,45 @@
{
"name": "@superset-ui/legacy-plugin-chart-sunburst",
"version": "0.0.0",
"description": "Superset Legacy Chart - Sunburst",
"sideEffects": false,
"main": "lib/index.js",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"repository": {
"type": "git",
"url": "git+https://github.com/apache-superset/superset-ui-legacy.git"
},
"keywords": [
"superset"
],
"author": "Superset",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/apache-superset/superset-ui-legacy/issues"
},
"homepage": "https://github.com/apache-superset/superset-ui-legacy#readme",
"publishConfig": {
"access": "public"
},
"dependencies": {
"@superset-ui/core": "^0.9.x",
"d3": "^3.5.17",
"prop-types": "^15.6.2"
},
"devDependencies": {
"@superset-ui/chart": "^0.9.x",
"@superset-ui/color": "^0.9.x",
"@superset-ui/number-format": "^0.9.x",
"@superset-ui/translation": "^0.9.x"
},
"peerDependencies": {
"@superset-ui/chart": "^0.9.x",
"@superset-ui/color": "^0.9.x",
"@superset-ui/number-format": "^0.9.x",
"@superset-ui/translation": "^0.9.x"
}
}

View File

@ -0,0 +1,22 @@
/**
* 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.
*/
import { reactify } from '@superset-ui/chart';
import Component from './Sunburst';
export default reactify(Component);

View File

@ -0,0 +1,68 @@
/**
* 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.
*/
.superset-legacy-chart-sunburst text {
text-rendering: optimizeLegibility;
}
.superset-legacy-chart-sunburst path {
stroke: #ddd;
stroke-width: 0.5px;
}
.superset-legacy-chart-sunburst .center-label {
text-anchor: middle;
fill: #333;
pointer-events: none;
}
.superset-legacy-chart-sunburst .path-abs-percent {
font-size: 3em;
font-weight: 700;
}
.superset-legacy-chart-sunburst .path-cond-percent {
font-size: 2em;
}
.superset-legacy-chart-sunburst .path-metrics {
color: #777;
}
.superset-legacy-chart-sunburst .path-ratio {
color: #777;
}
.superset-legacy-chart-sunburst .breadcrumbs text {
font-weight: 600;
font-size: 1.2em;
text-anchor: middle;
fill: #333;
}
/* dashboard specific */
.dashboard .superset-legacy-chart-sunburst text {
font-size: 1em;
}
.dashboard .superset-legacy-chart-sunburst .path-abs-percent {
font-size: 2em;
font-weight: 700;
}
.dashboard .superset-legacy-chart-sunburst .path-cond-percent {
font-size: 1.5em;
}
.dashboard .superset-legacy-chart-sunburst .path-metrics {
font-size: 1em;
}
.dashboard .superset-legacy-chart-sunburst .path-ratio {
font-size: 1em;
}

View File

@ -0,0 +1,431 @@
/**
* 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, sort-keys, no-magic-numbers */
/* eslint-disable complexity, no-plusplus, no-continue, babel/no-invalid-this */
import d3 from 'd3';
import PropTypes from 'prop-types';
import { CategoricalColorNamespace } from '@superset-ui/color';
import { getNumberFormatter, NumberFormats } from '@superset-ui/number-format';
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,
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 Sunburst(element, props) {
const container = d3.select(element);
container.classed('superset-legacy-chart-sunburst', true);
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 = 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 colorScale;
let breadcrumbs;
let vis;
let arcs;
let gMiddleText; // dom handles
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
// 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(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 ? colorFn(d.name) : colorScale(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 ? colorFn(d.name) : colorScale(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();
gMiddleText
.append('text')
.attr('class', 'path-abs-percent')
.attr('y', yOffsets[offsetIndex++])
.text(`${absolutePercString} of total`);
if (conditionalPercString) {
gMiddleText
.append('text')
.attr('class', 'path-cond-percent')
.attr('y', yOffsets[offsetIndex++])
.text(`${conditionalPercString} of parent`);
}
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)}`
}`,
);
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.indexOf(node) >= 0)
.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() {
d3.select(this).on('mouseenter', mouseenter);
});
}
function buildHierarchy(rows) {
const root = {
name: 'root',
children: [],
};
// each record [groupby1val, groupby2val, (<string> or 0)n, m1, m2]
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const m1 = Number(row[row.length - 2]);
const m2 = Number(row[row.length - 1]);
const levels = row.slice(0, row.length - 2);
if (Number.isNaN(m1)) {
// e.g. if this is a header row
continue;
}
let currentNode = root;
for (let level = 0; level < levels.length; level++) {
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;
let currChild;
if (!isLeafNode) {
// Not yet at the end of the sequence; move down the tree.
let foundChild = false;
for (let k = 0; k < children.length; k++) {
currChild = children[k];
if (currChild.name === nodeName && currChild.level === level) {
// must match name AND level
childNode = currChild;
foundChild = true;
break;
}
}
// If we don't already have a child node for this branch, create it.
if (!foundChild) {
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++) {
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;
}
// 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
let ext;
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']);
}
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 ? colorFn(d.name) : colorScale(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;

View File

@ -0,0 +1,40 @@
/**
* 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.
*/
import { t } from '@superset-ui/translation';
import { ChartMetadata, ChartPlugin } from '@superset-ui/chart';
import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
credits: ['https://bl.ocks.org/kerryrodden/7090426'],
description: '',
name: t('Sunburst Chart'),
thumbnail,
useLegacyApi: true,
});
export default class SunburstChartPlugin extends ChartPlugin {
constructor() {
super({
loadChart: () => import('./ReactSunburst.js'),
metadata,
transformProps,
});
}
}

View File

@ -0,0 +1,31 @@
/**
* 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 sort-keys */
export default function transformProps(chartProps) {
const { width, height, formData, payload } = chartProps;
const { colorScheme, metric, secondaryMetric } = formData;
return {
width,
height,
data: payload.data,
colorScheme,
metrics: [metric, secondaryMetric],
};
}

View File

@ -0,0 +1,54 @@
/* eslint-disable babel/no-invalid-this, no-plusplus */
/*
Utility function that takes a d3 svg:text selection and a max width, and splits the
text's text across multiple tspan lines such that any given line does not exceed max width
If text does not span multiple lines AND adjustedY is passed,
will set the text to the passed val
*/
import d3 from 'd3';
export default function wrapSvgText(text, width, adjustedY) {
const lineHeight = 1;
// ems
text.each(function each() {
const d3Text = d3.select(this);
const words = d3Text.text().split(/\s+/);
let word;
let line = [];
let lineNumber = 0;
const x = d3Text.attr('x');
const y = d3Text.attr('y');
const dy = parseFloat(d3Text.attr('dy'));
let tspan = d3Text
.text(null)
.append('tspan')
.attr('x', x)
.attr('y', y)
.attr('dy', `${dy}em`);
let didWrap = false;
for (let i = 0; i < words.length; i++) {
word = words[i];
line.push(word);
tspan.text(line.join(' '));
if (tspan.node().getComputedTextLength() > width) {
line.pop();
// remove word that pushes over the limit
tspan.text(line.join(' '));
line = [word];
tspan = d3Text
.append('tspan')
.attr('x', x)
.attr('y', y)
.attr('dy', `${++lineNumber * lineHeight + dy}em`)
.text(word);
didWrap = true;
}
}
if (!didWrap && typeof adjustedY !== 'undefined') {
tspan.attr('y', adjustedY);
}
});
}

View File

@ -22,7 +22,7 @@ import transformProps from './transformProps';
import thumbnail from './images/thumbnail.png';
const metadata = new ChartMetadata({
credits: ['https://d3js.org'],
credits: ['https://bl.ocks.org/mbostock/911ad09bdead40ec0061'],
description: '',
name: t('Treemap'),
thumbnail,