diff --git a/README.md b/README.md index 013ec74f9473..86e8db159ebf 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Superset [![PyPI](https://img.shields.io/pypi/pyversions/superset.svg?maxAge=2592000)](https://pypi.python.org/pypi/superset) [![Requirements Status](https://requires.io/github/airbnb/superset/requirements.svg?branch=master)](https://requires.io/github/airbnb/superset/requirements/?branch=master) [![Join the chat at https://gitter.im/airbnb/superset](https://badges.gitter.im/airbnb/superset.svg)](https://gitter.im/airbnb/superset?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -[![Documentation](https://img.shields.io/badge/docs-airbnb.io-blue.svg)](http://airbnb.io/superset/) +[![Documentation](https://img.shields.io/badge/docs-apache.org-blue.svg)](https://superset.incubator.apache.org) [![dependencies Status](https://david-dm.org/airbnb/superset/status.svg?path=superset/assets)](https://david-dm.org/airbnb/superset?path=superset/assets) ({ - 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 1e2eec23173f..85aeff6e59a6 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', @@ -635,6 +637,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 e7b432425ee2..5d5022def10b 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/stylesheets/superset.css b/superset/assets/stylesheets/superset.css index d4469e447c3a..2cbe27fa9d91 100644 --- a/superset/assets/stylesheets/superset.css +++ b/superset/assets/stylesheets/superset.css @@ -81,7 +81,6 @@ input[type="checkbox"] { display: inline-block; width: 16px; height: 16px; - float: right; } .widget-is-cached { diff --git a/superset/assets/visualizations/chord.css b/superset/assets/visualizations/chord.css new file mode 100644 index 000000000000..d7471ba402d1 --- /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 000000000000..c2b3c3498e7c --- /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 e078cb214840..68abddf5e353 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 75cb4113b7d8..a8cf3bfe5b60 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,