diff --git a/superset/assets/src/SqlLab/components/SqlEditor.jsx b/superset/assets/src/SqlLab/components/SqlEditor.jsx index a4cb4eb0557ec..96c26e98df375 100644 --- a/superset/assets/src/SqlLab/components/SqlEditor.jsx +++ b/superset/assets/src/SqlLab/components/SqlEditor.jsx @@ -236,7 +236,7 @@ class SqlEditor extends React.PureComponent { {ctasControls} diff --git a/superset/assets/src/explore/controls.jsx b/superset/assets/src/explore/controls.jsx index e2f31f5796be9..4b39ecb281295 100644 --- a/superset/assets/src/explore/controls.jsx +++ b/superset/assets/src/explore/controls.jsx @@ -495,6 +495,7 @@ export const controls = { ...metric, label: t('Color Metric'), default: null, + validators: [], description: t('A metric to use for color'), }, select_country: { @@ -787,6 +788,7 @@ export const controls = { link_length: { type: 'SelectControl', + renderTrigger: true, freeForm: true, label: t('Link Length'), default: '200', @@ -796,6 +798,7 @@ export const controls = { charge: { type: 'SelectControl', + renderTrigger: true, freeForm: true, label: t('Charge'), default: '-500', @@ -1249,7 +1252,8 @@ export const controls = { type: 'SelectControl', label: t('Rotation'), choices: formatSelectOptions(['random', 'flat', 'square']), - default: 'random', + renderTrigger: true, + default: 'square', description: t('Rotation to apply to words in the cloud'), }, @@ -1310,6 +1314,7 @@ export const controls = { type: 'TextControl', isInt: true, label: t('Font Size From'), + renderTrigger: true, default: '20', description: t('Font size for the smallest value in the list'), }, @@ -1318,6 +1323,7 @@ export const controls = { type: 'TextControl', isInt: true, label: t('Font Size To'), + renderTrigger: true, default: '150', description: t('Font size for the biggest value in the list'), }, @@ -1394,6 +1400,7 @@ export const controls = { type: 'CheckboxControl', label: t('Data Table'), default: false, + renderTrigger: true, description: t('Whether to display the interactive data table'), }, diff --git a/superset/assets/src/explore/visTypes.jsx b/superset/assets/src/explore/visTypes.jsx index 2bd9a32ba4e4e..e2eed23111b94 100644 --- a/superset/assets/src/explore/visTypes.jsx +++ b/superset/assets/src/explore/visTypes.jsx @@ -1037,9 +1037,9 @@ export const visTypes = { label: t('Query'), expanded: true, controlSetRows: [ + ['series'], ['metric'], ['adhoc_filters'], - ['series'], ['row_limit', null], ], }, @@ -1393,7 +1393,7 @@ export const visTypes = { }, directed_force: { - label: t('Directed Force Layout'), + label: t('Force-directed Graph'), controlPanelSections: [ { label: t('Query'), @@ -1508,6 +1508,7 @@ export const visTypes = { ['country_fieldtype'], ['metric'], ['adhoc_filters'], + ['row_limit'], ], }, { @@ -1587,13 +1588,15 @@ export const visTypes = { ['metrics'], ['secondary_metric'], ['adhoc_filters'], - ['limit'], + ['limit', 'row_limit'], ], }, { label: t('Options'), + expanded: true, controlSetRows: [ ['show_datatable', 'include_series'], + ['linear_color_scheme'], ], }, ], diff --git a/superset/assets/src/visualizations/chord.jsx b/superset/assets/src/visualizations/chord.jsx index 54b52617af2db..2a7cdf1a87949 100644 --- a/superset/assets/src/visualizations/chord.jsx +++ b/superset/assets/src/visualizations/chord.jsx @@ -1,18 +1,36 @@ /* eslint-disable no-param-reassign */ import d3 from 'd3'; +import PropTypes from 'prop-types'; import { getColorFromScheme } from '../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 propTypes = { + data: PropTypes.shape({ + nodes: PropTypes.arrayOf(PropTypes.string), + matrix: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.number)), + }), + width: PropTypes.number, + height: PropTypes.number, + numberFormat: PropTypes.string, + colorScheme: PropTypes.string, +}; + +function chordVis(element, props) { + PropTypes.checkPropTypes(propTypes, props, 'prop', 'ChordVis'); + + const { + data, + width, + height, + numberFormat, + colorScheme, + } = props; + + element.innerHTML = ''; + + const div = d3.select(element); + const { nodes, matrix } = data; + const f = d3.format(numberFormat); const outerRadius = Math.min(width, height) / 2 - 10; const innerRadius = outerRadius - 24; @@ -20,56 +38,56 @@ function chordViz(slice, json) { let chord; const arc = d3.svg.arc() - .innerRadius(innerRadius) - .outerRadius(outerRadius); + .innerRadius(innerRadius) + .outerRadius(outerRadius); const layout = d3.layout.chord() - .padding(0.04) - .sortSubgroups(d3.descending) - .sortChords(d3.descending); + .padding(0.04) + .sortSubgroups(d3.descending) + .sortChords(d3.descending); const path = d3.svg.chord() - .radius(innerRadius); + .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})`); + .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); + .attr('r', outerRadius); // Compute the chord layout. - layout.matrix(json.data.matrix); + layout.matrix(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); - }); + .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) => getColorFromScheme(nodes[i], slice.formData.color_scheme)); + .attr('id', (d, i) => 'group' + i) + .attr('d', arc) + .style('fill', (d, i) => getColorFromScheme(nodes[i], colorScheme)); // Add a text label. const groupText = group.append('text') - .attr('x', 6) - .attr('dy', 15); + .attr('x', 6) + .attr('dy', 15); groupText.append('textPath') - .attr('xlink:href', (d, i) => `#group${i}`) - .text((d, i) => nodes[i]); + .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(); @@ -78,14 +96,14 @@ function chordViz(slice, json) { // 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 => getColorFromScheme(nodes[d.source.index], slice.formData.color_scheme)) - .attr('d', path); + .data(layout.chords) + .enter().append('path') + .attr('class', 'chord') + .on('mouseover', (d) => { + chord.classed('fade', p => p !== d); + }) + .style('fill', d => getColorFromScheme(nodes[d.source.index], colorScheme)) + .attr('d', path); // Add an elaborate mouseover title for each chord. chord.append('title').text(function (d) { @@ -98,4 +116,20 @@ function chordViz(slice, json) { }); } -module.exports = chordViz; +chordVis.propTypes = propTypes; + +function adaptor(slice, payload) { + const { selector, formData } = slice; + const { y_axis_format: numberFormat, color_scheme: colorScheme } = formData; + const element = document.querySelector(selector); + + return chordVis(element, { + data: payload.data, + width: slice.width(), + height: slice.height(), + numberFormat, + colorScheme, + }); +} + +export default adaptor; diff --git a/superset/assets/src/visualizations/directed_force.js b/superset/assets/src/visualizations/directed_force.js index b95829f9fd272..b3bf0f3b971fa 100644 --- a/superset/assets/src/visualizations/directed_force.js +++ b/superset/assets/src/visualizations/directed_force.js @@ -1,18 +1,34 @@ /* eslint-disable no-param-reassign */ import d3 from 'd3'; - -require('./directed_force.css'); +import PropTypes from 'prop-types'; +import './directed_force.css'; + +const propTypes = { + data: PropTypes.arrayOf(PropTypes.shape({ + source: PropTypes.string, + target: PropTypes.string, + value: PropTypes.number, + })), + width: PropTypes.number, + height: PropTypes.number, + linkLength: PropTypes.number, + charge: PropTypes.number, +}; /* Modified from http://bl.ocks.org/d3noob/5141278 */ -const directedForceVis = function (slice, json) { - const div = d3.select(slice.selector); - const width = slice.width(); - const height = slice.height(); - const fd = slice.formData; - const linkLength = fd.link_length || 200; - const charge = fd.charge || -500; - - const links = json.data; +function ForceDirectedGraph(element, props) { + PropTypes.checkPropTypes(propTypes, props, 'prop', 'ForceDirectedGraph'); + + const { + data, + width, + height, + linkLength = 200, + charge = -500, + } = props; + const div = d3.select(element); + + const links = data; const nodes = {}; // Compute the distinct nodes from the links. links.forEach(function (link) { @@ -73,73 +89,73 @@ const directedForceVis = function (slice, json) { /* eslint-enable no-use-before-define */ const force = d3.layout.force() - .nodes(d3.values(nodes)) - .links(links) - .size([width, height]) - .linkDistance(linkLength) - .charge(charge) - .on('tick', tick) - .start(); + .nodes(d3.values(nodes)) + .links(links) + .size([width, height]) + .linkDistance(linkLength) + .charge(charge) + .on('tick', tick) + .start(); div.selectAll('*').remove(); const svg = div.append('svg') - .attr('width', width) - .attr('height', height); + .attr('width', width) + .attr('height', height); // build the arrow. svg.append('svg:defs').selectAll('marker') - .data(['end']) // Different link/path types can be defined here + .data(['end']) // Different link/path types can be defined here .enter() .append('svg:marker') // This section adds in the arrows - .attr('id', String) - .attr('viewBox', '0 -5 10 10') - .attr('refX', 15) - .attr('refY', -1.5) - .attr('markerWidth', 6) - .attr('markerHeight', 6) - .attr('orient', 'auto') + .attr('id', String) + .attr('viewBox', '0 -5 10 10') + .attr('refX', 15) + .attr('refY', -1.5) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') .append('svg:path') - .attr('d', 'M0,-5L10,0L0,5'); + .attr('d', 'M0,-5L10,0L0,5'); const edgeScale = d3.scale.linear() .range([0.1, 0.5]); // add the links and the arrows const path = svg.append('svg:g').selectAll('path') - .data(force.links()) + .data(force.links()) .enter() .append('svg:path') - .attr('class', 'link') - .style('opacity', function (d) { - return edgeScale(d.value / d.target.max); - }) - .attr('marker-end', 'url(#end)'); + .attr('class', 'link') + .style('opacity', function (d) { + return edgeScale(d.value / d.target.max); + }) + .attr('marker-end', 'url(#end)'); // define the nodes const node = svg.selectAll('.node') - .data(force.nodes()) + .data(force.nodes()) .enter() - .append('g') - .attr('class', 'node') + .append('g') + .attr('class', 'node') .on('mouseenter', function () { d3.select(this) - .select('circle') - .transition() - .style('stroke-width', 5); + .select('circle') + .transition() + .style('stroke-width', 5); d3.select(this) - .select('text') - .transition() - .style('font-size', 25); + .select('text') + .transition() + .style('font-size', 25); }) .on('mouseleave', function () { d3.select(this) - .select('circle') - .transition() - .style('stroke-width', 1.5); + .select('circle') + .transition() + .style('stroke-width', 1.5); d3.select(this) - .select('text') - .transition() - .style('font-size', 12); + .select('text') + .transition() + .style('font-size', 12); }) .call(force.drag); @@ -148,21 +164,33 @@ const directedForceVis = function (slice, json) { return Math.sqrt(d.total); }); const circleScale = d3.scale.linear() - .domain(ext) - .range([3, 30]); + .domain(ext) + .range([3, 30]); node.append('circle') - .attr('r', function (d) { - return circleScale(Math.sqrt(d.total)); - }); + .attr('r', function (d) { + return circleScale(Math.sqrt(d.total)); + }); // add the text node.append('text') - .attr('x', 6) - .attr('dy', '.35em') - .text(function (d) { - return d.name; + .attr('x', 6) + .attr('dy', '.35em') + .text(d => d.name); +} + +function adaptor(slice, payload) { + const { selector, formData } = slice; + const { link_length: linkLength, charge } = formData; + const element = document.querySelector(selector); + + return ForceDirectedGraph(element, { + data: payload.data, + width: slice.width(), + height: slice.height(), + linkLength, + charge, }); -}; +} -module.exports = directedForceVis; +export default adaptor; diff --git a/superset/assets/src/visualizations/parallel_coordinates.js b/superset/assets/src/visualizations/parallel_coordinates.js index f60904994d97f..8e4ff5e6ccbd7 100644 --- a/superset/assets/src/visualizations/parallel_coordinates.js +++ b/superset/assets/src/visualizations/parallel_coordinates.js @@ -1,6 +1,7 @@ import d3 from 'd3'; import '../../vendor/parallel_coordinates/d3.parcoords.css'; import './parallel_coordinates.css'; +import { colorScalerFactory } from '../modules/colors'; d3.parcoords = require('../../vendor/parallel_coordinates/d3.parcoords.js'); d3.divgrid = require('../../vendor/parallel_coordinates/divgrid.js'); @@ -12,29 +13,24 @@ function parallelCoordVis(slice, payload) { const fd = slice.formData; const data = payload.data; - let cols = fd.metrics; + const metrics = fd.metrics.map(m => m.label || m); + + let cols = metrics; if (fd.include_series) { - cols = [fd.series].concat(fd.metrics); + cols = [fd.series].concat(metrics); } const ttypes = {}; ttypes[fd.series] = 'string'; - fd.metrics.forEach(function (v) { + metrics.forEach(function (v) { ttypes[v] = 'number'; }); - let ext = d3.extent(data, function (d) { - return d[fd.secondary_metric]; - }); - ext = [ext[0], (ext[1] - ext[0]) / 2, ext[1]]; - const cScale = d3.scale.linear() - .domain(ext) - .range(['red', 'grey', 'blue']) - .interpolate(d3.interpolateLab); - - const color = function (d) { - return cScale(d[fd.secondary_metric]); - }; + const secondaryMetric = fd.secondary_metric ? fd.secondary_metric.label : fd.secondary_metric; + const colorScaler = fd.secondary_metric ? + colorScalerFactory(fd.linear_color_scheme, data, d => d[secondaryMetric]) : + () => 'grey'; + const color = d => colorScaler(d[secondaryMetric]); const container = d3.select(slice.selector); container.selectAll('*').remove(); const effHeight = fd.show_datatable ? (slice.height() / 2) : slice.height(); diff --git a/superset/assets/src/visualizations/sunburst.js b/superset/assets/src/visualizations/sunburst.js index 0c9c2cc5eee6e..a00d28b82430c 100644 --- a/superset/assets/src/visualizations/sunburst.js +++ b/superset/assets/src/visualizations/sunburst.js @@ -3,7 +3,7 @@ import d3 from 'd3'; import { getColorFromScheme } from '../modules/colors'; import { wrapSvgText } from '../modules/utils'; -require('./sunburst.css'); +import './sunburst.css'; // Modified from http://bl.ocks.org/kerryrodden/7090426 function sunburstVis(slice, payload) { @@ -250,7 +250,7 @@ function sunburstVis(slice, payload) { let currentNode = root; for (let level = 0; level < levels.length; level++) { const children = currentNode.children || []; - const nodeName = levels[level]; + 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; diff --git a/superset/assets/src/visualizations/word_cloud.js b/superset/assets/src/visualizations/word_cloud.js index f09c16ecd3898..6fdd96237e1fa 100644 --- a/superset/assets/src/visualizations/word_cloud.js +++ b/superset/assets/src/visualizations/word_cloud.js @@ -1,63 +1,103 @@ -/* eslint-disable no-use-before-define */ import d3 from 'd3'; +import PropTypes from 'prop-types'; import cloudLayout from 'd3-cloud'; import { getColorFromScheme } from '../modules/colors'; -function wordCloudChart(slice, payload) { - const chart = d3.select(slice.selector); - const data = payload.data; - const fd = slice.formData; - const range = [ - fd.size_from, - fd.size_to, - ]; - const rotation = fd.rotation; - let fRotation; - if (rotation === 'square') { - fRotation = () => Math.floor((Math.random() * 2) * 90); - } else if (rotation === 'flat') { - fRotation = () => 0; - } else { - fRotation = () => Math.floor(((Math.random() * 6) - 3) * 30); - } - const size = [slice.width(), slice.height()]; +const ROTATION = { + square: () => Math.floor((Math.random() * 2)) * 90, + flat: () => 0, + random: () => Math.floor(((Math.random() * 6) - 3)) * 30, +}; + +const propTypes = { + data: PropTypes.arrayOf(PropTypes.shape({ + size: PropTypes.number, + text: PropTypes.string, + })), + width: PropTypes.number, + height: PropTypes.number, + rotation: PropTypes.string, + sizeRange: PropTypes.arrayOf(PropTypes.number), + colorScheme: PropTypes.string, +}; + +function wordCloud(element, props) { + PropTypes.checkPropTypes(propTypes, props, 'prop', 'WordCloud'); + + const { + data, + width, + height, + rotation, + sizeRange, + colorScheme, + } = props; + + console.log('data', data); + + const chart = d3.select(element); + const size = [width, height]; + const rotationFn = ROTATION[rotation] || ROTATION.random; const scale = d3.scale.linear() - .range(range) - .domain(d3.extent(data, function (d) { - return d.size; - })); + .range(sizeRange) + .domain(d3.extent(data, d => d.size)); + + const layout = cloudLayout() + .size(size) + .words(data) + .padding(5) + .rotate(rotationFn) + .font('Helvetica') + .fontWeight('bold') + .fontSize(d => scale(d.size)); function draw(words) { chart.selectAll('*').remove(); + const [w, h] = layout.size(); + chart.append('svg') - .attr('width', layout.size()[0]) - .attr('height', layout.size()[1]) - .append('g') - .attr('transform', `translate(${layout.size()[0] / 2},${layout.size()[1] / 2})`) - .selectAll('text') - .data(words) - .enter() - .append('text') - .style('font-size', d => d.size + 'px') - .style('font-family', 'Impact') - .style('fill', d => getColorFromScheme(d.text, fd.color_scheme)) - .attr('text-anchor', 'middle') - .attr('transform', d => `translate(${d.x}, ${d.y}) rotate(${d.rotate})`) - .text(d => d.text); + .attr('width', w) + .attr('height', h) + .append('g') + .attr('transform', `translate(${w / 2},${h / 2})`) + .selectAll('text') + .data(words) + .enter() + .append('text') + .style('font-size', d => `${d.size}px`) + .style('font-weight', 'bold') + .style('font-family', 'Helvetica') + .style('fill', d => getColorFromScheme(d.text, colorScheme)) + .attr('text-anchor', 'middle') + .attr('transform', d => `translate(${d.x}, ${d.y}) rotate(${d.rotate})`) + .text(d => d.text); } - const layout = cloudLayout() - .size(size) - .words(data) - .padding(5) - .rotate(fRotation) - .font('serif') - .fontSize(d => scale(d.size)) - .on('end', draw); - - layout.start(); + layout.on('end', draw).start(); +} + +wordCloud.propTypes = propTypes; + +function adaptor(slice, payload) { + const { selector, formData } = slice; + const { + rotation, + size_to: sizeTo, + size_from: sizeFrom, + color_scheme: colorScheme, + } = formData; + const element = document.querySelector(selector); + + return wordCloud(element, { + data: payload.data, + width: slice.width(), + height: slice.height(), + rotation, + sizeRange: [sizeFrom, sizeTo], + colorScheme, + }); } -module.exports = wordCloudChart; +export default adaptor; diff --git a/superset/viz.py b/superset/viz.py index 2f90fccc6230c..9dee6f35ab5f8 100644 --- a/superset/viz.py +++ b/superset/viz.py @@ -1725,8 +1725,6 @@ class WorldMapViz(BaseViz): def query_obj(self): qry = super(WorldMapViz, self).query_obj() - qry['metrics'] = [ - self.form_data['metric'], self.form_data['secondary_metric']] qry['groupby'] = [self.form_data['entity']] return qry @@ -1736,6 +1734,7 @@ def get_data(self, df): cols = [fd.get('entity')] metric = self.get_metric_label(fd.get('metric')) secondary_metric = self.get_metric_label(fd.get('secondary_metric')) + columns = ['country', 'm1', 'm2'] if metric == secondary_metric: ndf = df[cols] # df[metric] will be a DataFrame @@ -1743,10 +1742,14 @@ def get_data(self, df): ndf['m1'] = df[metric].iloc[:, 0] ndf['m2'] = ndf['m1'] else: - cols += [metric, secondary_metric] + if secondary_metric: + cols += [metric, secondary_metric] + else: + cols += [metric] + columns = ['country', 'm1'] ndf = df[cols] df = ndf - df.columns = ['country', 'm1', 'm2'] + df.columns = columns d = df.to_dict(orient='records') for row in d: country = None @@ -1844,10 +1847,6 @@ class ParallelCoordinatesViz(BaseViz): def query_obj(self): d = super(ParallelCoordinatesViz, self).query_obj() fd = self.form_data - d['metrics'] = copy.copy(fd.get('metrics')) - second = fd.get('secondary_metric') - if second not in d['metrics']: - d['metrics'] += [second] d['groupby'] = [fd.get('series')] return d