diff --git a/superset/assets/images/viz_thumbnails/chord.png b/superset/assets/images/viz_thumbnails/chord.png
new file mode 100644
index 0000000000000..a4a30b6aebc63
Binary files /dev/null and b/superset/assets/images/viz_thumbnails/chord.png differ
diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx
index da6ff386ad64b..cf95c5505463b 100644
--- a/superset/assets/javascripts/explore/stores/controls.jsx
+++ b/superset/assets/javascripts/explore/stores/controls.jsx
@@ -334,11 +334,14 @@ export const controls = {
type: 'SelectControl',
multi: true,
label: 'Columns',
- mapStateToProps: state => ({
- choices: (state.datasource) ? state.datasource.gb_cols : [],
- }),
default: [],
description: 'One or many controls to pivot as columns',
+ optionRenderer: c => ,
+ valueRenderer: c => ,
+ valueKey: 'column_name',
+ mapStateToProps: state => ({
+ options: (state.datasource) ? state.datasource.columns : [],
+ }),
},
all_columns: {
diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js
index 3bc7fd626bead..0e589c9d9b208 100644
--- a/superset/assets/javascripts/explore/stores/visTypes.js
+++ b/superset/assets/javascripts/explore/stores/visTypes.js
@@ -1,5 +1,7 @@
import { D3_TIME_FORMAT_OPTIONS } from './controls';
+import * as v from '../validators';
+
export const sections = {
druidTimeSeries: {
label: 'Time',
@@ -622,6 +624,37 @@ const visTypes = {
},
},
},
+ chord: {
+ label: 'Chord Diagram',
+ controlPanelSections: [
+ {
+ label: null,
+ controlSetRows: [
+ ['groupby', 'columns'],
+ ['metric'],
+ ['row_limit', 'y_axis_format'],
+ ],
+ },
+ ],
+ controlOverrides: {
+ y_axis_format: {
+ label: 'Number format',
+ description: 'Choose a number format',
+ },
+ groupby: {
+ label: 'Source',
+ multi: false,
+ validators: [v.nonEmpty],
+ description: 'Choose a source',
+ },
+ columns: {
+ label: 'Target',
+ multi: false,
+ validators: [v.nonEmpty],
+ description: 'Choose a target',
+ },
+ },
+ },
country_map: {
label: 'Country Map',
controlPanelSections: [
diff --git a/superset/assets/package.json b/superset/assets/package.json
index 4cc09cfca27a2..07d8ff7d9422b 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -65,7 +65,7 @@
"react-ace": "^5.0.1",
"react-addons-css-transition-group": "^15.6.0",
"react-addons-shallow-compare": "^15.4.2",
- "react-alert": "^2.0.1",
+ "react-alert": "^1.0.14",
"react-bootstrap": "^0.31.0",
"react-bootstrap-table": "^3.1.7",
"react-dom": "^15.5.1",
diff --git a/superset/assets/visualizations/chord.css b/superset/assets/visualizations/chord.css
new file mode 100644
index 0000000000000..d7471ba402d13
--- /dev/null
+++ b/superset/assets/visualizations/chord.css
@@ -0,0 +1,17 @@
+.chord svg #circle circle {
+ fill: none;
+ pointer-events: all;
+}
+
+.chord svg .group path {
+ fill-opacity: .6;
+}
+
+.chord svg path.chord {
+ stroke: #000;
+ stroke-width: .25px;
+}
+
+.chord svg #circle:hover path.fade {
+ opacity: 0.2;
+}
diff --git a/superset/assets/visualizations/chord.jsx b/superset/assets/visualizations/chord.jsx
new file mode 100644
index 0000000000000..c2b3c3498e7c4
--- /dev/null
+++ b/superset/assets/visualizations/chord.jsx
@@ -0,0 +1,101 @@
+/* eslint-disable no-param-reassign */
+import d3 from 'd3';
+import { category21 } from '../javascripts/modules/colors';
+import './chord.css';
+
+function chordViz(slice, json) {
+ slice.container.html('');
+
+ const div = d3.select(slice.selector);
+ const nodes = json.data.nodes;
+ const fd = slice.formData;
+ const f = d3.format(fd.y_axis_format);
+
+ const width = slice.width();
+ const height = slice.height();
+
+ const outerRadius = Math.min(width, height) / 2 - 10;
+ const innerRadius = outerRadius - 24;
+
+ let chord;
+
+ const arc = d3.svg.arc()
+ .innerRadius(innerRadius)
+ .outerRadius(outerRadius);
+
+ const layout = d3.layout.chord()
+ .padding(0.04)
+ .sortSubgroups(d3.descending)
+ .sortChords(d3.descending);
+
+ const path = d3.svg.chord()
+ .radius(innerRadius);
+
+ const svg = div.append('svg')
+ .attr('width', width)
+ .attr('height', height)
+ .on('mouseout', () => chord.classed('fade', false))
+ .append('g')
+ .attr('id', 'circle')
+ .attr('transform', `translate(${width / 2}, ${height / 2})`);
+
+ svg.append('circle')
+ .attr('r', outerRadius);
+
+ // Compute the chord layout.
+ layout.matrix(json.data.matrix);
+
+ const group = svg.selectAll('.group')
+ .data(layout.groups)
+ .enter().append('g')
+ .attr('class', 'group')
+ .on('mouseover', (d, i) => {
+ chord.classed('fade', p => p.source.index !== i && p.target.index !== i);
+ });
+
+ // Add a mouseover title.
+ group.append('title').text((d, i) => `${nodes[i]}: ${f(d.value)}`);
+
+ // Add the group arc.
+ const groupPath = group.append('path')
+ .attr('id', (d, i) => 'group' + i)
+ .attr('d', arc)
+ .style('fill', (d, i) => category21(nodes[i]));
+
+ // Add a text label.
+ const groupText = group.append('text')
+ .attr('x', 6)
+ .attr('dy', 15);
+
+ groupText.append('textPath')
+ .attr('xlink:href', (d, i) => `#group${i}`)
+ .text((d, i) => nodes[i]);
+ // Remove the labels that don't fit. :(
+ groupText.filter(function (d, i) {
+ return groupPath[0][i].getTotalLength() / 2 - 16 < this.getComputedTextLength();
+ })
+ .remove();
+
+ // Add the chords.
+ chord = svg.selectAll('.chord')
+ .data(layout.chords)
+ .enter().append('path')
+ .attr('class', 'chord')
+ .on('mouseover', (d) => {
+ chord.classed('fade', p => p !== d);
+ })
+ .style('fill', d => category21(nodes[d.source.index]))
+ .attr('d', path);
+
+ // Add an elaborate mouseover title for each chord.
+ chord.append('title').text(function (d) {
+ return nodes[d.source.index]
+ + ' → ' + nodes[d.target.index]
+ + ': ' + f(d.source.value)
+ + '\n' + nodes[d.target.index]
+ + ' → ' + nodes[d.source.index]
+ + ': ' + f(d.target.value);
+ });
+}
+
+module.exports = chordViz;
diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js
index e078cb214840a..68abddf5e3532 100644
--- a/superset/assets/visualizations/main.js
+++ b/superset/assets/visualizations/main.js
@@ -10,6 +10,7 @@ const vizMap = {
cal_heatmap: require('./cal_heatmap.js'),
compare: require('./nvd3_vis.js'),
directed_force: require('./directed_force.js'),
+ chord: require('./chord.jsx'),
dist_bar: require('./nvd3_vis.js'),
filter_box: require('./filter_box.jsx'),
heatmap: require('./heatmap.js'),
diff --git a/superset/viz.py b/superset/viz.py
index 75cb4113b7d8d..a8cf3bfe5b605 100755
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -16,6 +16,7 @@
import zlib
from collections import OrderedDict, defaultdict
+from itertools import product
from datetime import datetime, timedelta
import pandas as pd
@@ -1231,6 +1232,39 @@ def get_data(self, df):
return df.to_dict(orient='records')
+class ChordViz(BaseViz):
+
+ """A Chord diagram"""
+
+ viz_type = "chord"
+ verbose_name = _("Directed Force Layout")
+ credits = 'Bostock'
+ is_timeseries = False
+
+ def query_obj(self):
+ qry = super(ChordViz, self).query_obj()
+ fd = self.form_data
+ qry['groupby'] = [fd.get('groupby'), fd.get('columns')]
+ qry['metrics'] = [fd.get('metric')]
+ return qry
+
+ def get_data(self, df):
+ df.columns = ['source', 'target', 'value']
+
+ # Preparing a symetrical matrix like d3.chords calls for
+ nodes = list(set(df['source']) | set(df['target']))
+ matrix = {}
+ for source, target in product(nodes, nodes):
+ matrix[(source, target)] = 0
+ for source, target, value in df.to_records(index=False):
+ matrix[(source, target)] = value
+ m = [[matrix[(n1, n2)] for n1 in nodes] for n2 in nodes]
+ return {
+ 'nodes': list(nodes),
+ 'matrix': m,
+ }
+
+
class CountryMapViz(BaseViz):
"""A country centric"""
@@ -1574,6 +1608,7 @@ def get_data(self, df):
DirectedForceViz,
SankeyViz,
CountryMapViz,
+ ChordViz,
WorldMapViz,
FilterBoxViz,
IFrameViz,