diff --git a/superset/assets/javascripts/explore/components/Control.jsx b/superset/assets/javascripts/explore/components/Control.jsx index b0dce3549fd95..6022b9f690fc8 100644 --- a/superset/assets/javascripts/explore/components/Control.jsx +++ b/superset/assets/javascripts/explore/components/Control.jsx @@ -10,6 +10,7 @@ import SelectControl from './controls/SelectControl'; import TextAreaControl from './controls/TextAreaControl'; import TextControl from './controls/TextControl'; import VizTypeControl from './controls/VizTypeControl'; +import ColorSchemeControl from './controls/ColorSchemeControl'; const controlMap = { BoundsControl, @@ -21,6 +22,7 @@ const controlMap = { TextAreaControl, TextControl, VizTypeControl, + ColorSchemeControl, }; const controlTypes = Object.keys(controlMap); diff --git a/superset/assets/javascripts/explore/components/controls/ColorSchemeControl.jsx b/superset/assets/javascripts/explore/components/controls/ColorSchemeControl.jsx new file mode 100644 index 0000000000000..db51792cfc696 --- /dev/null +++ b/superset/assets/javascripts/explore/components/controls/ColorSchemeControl.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Creatable } from 'react-select'; +import ControlHeader from '../ControlHeader'; + +import { colorScalerFactory } from '../../../modules/colors'; + +const propTypes = { + description: PropTypes.string, + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func, + value: PropTypes.string, + default: PropTypes.string, + choices: PropTypes.arrayOf(React.PropTypes.array).isRequired, + schemes: PropTypes.object.isRequired, + isLinear: PropTypes.bool, +}; + +const defaultProps = { + choices: [], + schemes: {}, + onChange: () => {}, +}; + +export default class ColorSchemeControl extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + scheme: this.props.value, + }; + + this.onChange = this.onChange.bind(this); + this.renderOption = this.renderOption.bind(this); + } + + onChange(option) { + const optionValue = option ? option.value : null; + this.props.onChange(optionValue); + this.setState({ scheme: optionValue }); + } + + renderOption(key) { + const currentScheme = key.value ? + this.props.schemes[key.value] : + this.props.schemes[defaultProps.value]; + + let colors = currentScheme; + if (this.props.isLinear) { + const colorScaler = colorScalerFactory(currentScheme); + colors = [...Array(20).keys()].map(d => (colorScaler(d / 20))); + } + + const list = colors.map((color, i) => ( +
  •  
  • + )); + return (); + } + + render() { + const selectProps = { + multi: false, + name: `select-${this.props.name}`, + placeholder: `Select (${this.props.choices.length})`, + default: this.props.default, + options: this.props.choices.map(choice => ({ value: choice[0], label: choice[1] })), + value: this.props.value, + autosize: false, + clearable: false, + onChange: this.onChange, + optionRenderer: this.renderOption, + valueRenderer: this.renderOption, + }; + return ( +
    + + +
    + ); + } +} + +ColorSchemeControl.propTypes = propTypes; +ColorSchemeControl.defaultProps = defaultProps; diff --git a/superset/assets/javascripts/explore/main.css b/superset/assets/javascripts/explore/main.css index 388be8f9fa784..5baea683d1996 100644 --- a/superset/assets/javascripts/explore/main.css +++ b/superset/assets/javascripts/explore/main.css @@ -60,3 +60,16 @@ cursor: not-allowed; border: 1px solid #aaa; } + +.color-scheme-container { + list-style: none; + margin: 0; + padding: 0; + display: flex; + align-items: center; +} +.color-scheme-container li { + flex-basis: 9px; + height: 10px; + margin: 9px 1px; +} diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index 3d33873c3c53d..febb3efa20889 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { formatSelectOptionsForRange, formatSelectOptions } from '../../modules/utils'; import * as v from '../validators'; +import { ALL_COLOR_SCHEMES, spectrums } from '../../modules/colors'; import MetricOption from '../../components/MetricOption'; import ColumnOption from '../../components/ColumnOption'; @@ -155,7 +156,7 @@ export const controls = { }, linear_color_scheme: { - type: 'SelectControl', + type: 'ColorSchemeControl', label: 'Linear Color Scheme', choices: [ ['fire', 'fire'], @@ -165,6 +166,9 @@ export const controls = { ], default: 'blue_white_yellow', description: '', + renderTrigger: true, + schemes: spectrums, + isLinear: true, }, normalize_across: { @@ -1307,5 +1311,15 @@ export const controls = { description: 'Leaf nodes that represent fewer than this number of events will be initially ' + 'hidden in the visualization', }, + + color_scheme: { + type: 'ColorSchemeControl', + label: 'Color Scheme', + default: 'bnbColors', + renderTrigger: true, + choices: Object.keys(ALL_COLOR_SCHEMES).map(s => ([s, s])), + description: 'The color scheme for rendering chart', + schemes: ALL_COLOR_SCHEMES, + }, }; export default controls; diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 7e04237a36dc8..81496fa8cabd8 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -18,6 +18,12 @@ export const sections = { ['slice_id', 'cache_timeout'], ], }, + colorScheme: { + label: 'Color Scheme', + controlSetRows: [ + ['color_scheme'], + ], + }, sqlaTimeSeries: { label: 'Time', description: 'Time related form attributes', @@ -83,6 +89,7 @@ export const visTypes = { { label: 'Chart Options', controlSetRows: [ + ['color_scheme'], ['metrics'], ['groupby'], ['columns'], @@ -119,6 +126,7 @@ export const visTypes = { ['pie_label_type'], ['donut', 'show_legend'], ['labels_outside'], + ['color_scheme'], ], }, ], @@ -133,6 +141,7 @@ export const visTypes = { { label: 'Chart Options', controlSetRows: [ + ['color_scheme'], ['show_brush', 'show_legend'], ['rich_tooltip', null], ['show_markers', 'x_axis_showminmax'], @@ -164,6 +173,7 @@ export const visTypes = { { label: 'Chart Options', controlSetRows: [ + ['color_scheme'], ['x_axis_format'], ], }, @@ -204,6 +214,7 @@ export const visTypes = { { label: 'Chart Options', controlSetRows: [ + ['color_scheme'], ['show_brush', 'show_legend', 'show_bar_value'], ['rich_tooltip', 'contribution'], ['line_interpolation', 'bar_stacked'], @@ -237,6 +248,7 @@ export const visTypes = { { label: 'Chart Options', controlSetRows: [ + ['color_scheme'], ['x_axis_format', 'y_axis_format'], ], }, @@ -260,6 +272,7 @@ export const visTypes = { controlSetRows: [ ['show_brush', 'show_legend'], ['line_interpolation', 'stacked_style'], + ['color_scheme'], ['rich_tooltip', 'contribution'], ['show_controls', null], ], @@ -383,6 +396,7 @@ export const visTypes = { ['series', 'metric', 'limit'], ['size_from', 'size_to'], ['rotation'], + ['color_scheme'], ], }, ], @@ -401,6 +415,7 @@ export const visTypes = { { label: 'Chart Options', controlSetRows: [ + ['color_scheme'], ['treemap_ratio'], ['number_format'], ], @@ -436,6 +451,7 @@ export const visTypes = { { label: 'Chart Options', controlSetRows: [ + ['color_scheme'], ['whisker_options'], ], }, @@ -455,6 +471,7 @@ export const visTypes = { { label: 'Chart Options', controlSetRows: [ + ['color_scheme'], ['show_legend', null], ], }, @@ -553,6 +570,7 @@ export const visTypes = { { label: 'Histogram Options', controlSetRows: [ + ['color_scheme'], ['link_length'], ], }, @@ -579,6 +597,7 @@ export const visTypes = { ['groupby'], ['metric', 'secondary_metric'], ['row_limit'], + ['color_scheme'], ], }, ], @@ -609,6 +628,7 @@ export const visTypes = { ['groupby'], ['metric'], ['row_limit'], + ['color_scheme'], ], }, ], @@ -655,6 +675,7 @@ export const visTypes = { ['groupby', 'columns'], ['metric'], ['row_limit', 'y_axis_format'], + ['color_scheme'], ], }, ], diff --git a/superset/assets/javascripts/modules/colors.js b/superset/assets/javascripts/modules/colors.js index 56c88afd2a764..9897e4c228e06 100644 --- a/superset/assets/javascripts/modules/colors.js +++ b/superset/assets/javascripts/modules/colors.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import d3 from 'd3'; // Color related utility functions go in this object -export const bnbColors = [ +const bnbColors = [ '#ff5a5f', // rausch '#7b0051', // hackb '#007A87', // kazan @@ -25,8 +25,55 @@ export const bnbColors = [ '#b37e00', '#988b4e', ]; +const d3Category10 = d3.scale.category10().range(); +const d3Category20 = d3.scale.category20().range(); +const d3Category20b = d3.scale.category20b().range(); +const d3Category20c = d3.scale.category20c().range(); +const googleCategory10c = [ + '#3366cc', + '#dc3912', + '#ff9900', + '#109618', + '#990099', + '#0099c6', + '#dd4477', + '#66aa00', + '#b82e2e', + '#316395', +]; +const googleCategory20c = [ + '#3366cc', + '#dc3912', + '#ff9900', + '#109618', + '#990099', + '#0099c6', + '#dd4477', + '#66aa00', + '#b82e2e', + '#316395', + '#994499', + '#22aa99', + '#aaaa11', + '#6633cc', + '#e67300', + '#8b0707', + '#651067', + '#329262', + '#5574a6', + '#3b3eac', +]; +export const ALL_COLOR_SCHEMES = { + bnbColors, + d3Category10, + d3Category20, + d3Category20b, + d3Category20c, + googleCategory10c, + googleCategory20c, +}; -const spectrums = { +export const spectrums = { blue_white_yellow: [ '#00d1c1', 'white', @@ -48,21 +95,25 @@ const spectrums = { ], }; -export const category21 = (function () { +export const getColorFromScheme = (function () { // Color factory const seen = {}; - return function (s) { + return function (s, scheme) { if (!s) { return; } + const selectedScheme = scheme ? ALL_COLOR_SCHEMES[scheme] : ALL_COLOR_SCHEMES.bnbColors; let stringifyS = String(s); // next line is for superset series that should have the same color stringifyS = stringifyS.replace('---', ''); - if (seen[stringifyS] === undefined) { - seen[stringifyS] = Object.keys(seen).length; + if (seen[selectedScheme] === undefined) { + seen[selectedScheme] = {}; + } + if (seen[selectedScheme][stringifyS] === undefined) { + seen[selectedScheme][stringifyS] = Object.keys(seen[selectedScheme]).length; } /* eslint consistent-return: 0 */ - return bnbColors[seen[stringifyS] % bnbColors.length]; + return selectedScheme[seen[selectedScheme][stringifyS] % selectedScheme.length]; }; }()); diff --git a/superset/assets/spec/javascripts/modules/colors_spec.jsx b/superset/assets/spec/javascripts/modules/colors_spec.jsx new file mode 100644 index 0000000000000..558547101a06e --- /dev/null +++ b/superset/assets/spec/javascripts/modules/colors_spec.jsx @@ -0,0 +1,22 @@ +import { it, describe } from 'mocha'; +import { expect } from 'chai'; + +import { ALL_COLOR_SCHEMES, getColorFromScheme } from '../../../javascripts/modules/colors'; + +describe('colors', () => { + it('default to bnbColors', () => { + const color1 = getColorFromScheme('CA'); + expect(color1).to.equal(ALL_COLOR_SCHEMES.bnbColors[0]); + }); + it('series with same scheme should have the same color', () => { + const color1 = getColorFromScheme('CA', 'bnbColors'); + const color2 = getColorFromScheme('CA', 'googleCategory20c'); + const color3 = getColorFromScheme('CA', 'bnbColors'); + const color4 = getColorFromScheme('NY', 'bnbColors'); + + expect(color1).to.equal(ALL_COLOR_SCHEMES.bnbColors[0]); + expect(color2).to.equal(ALL_COLOR_SCHEMES.googleCategory20c[0]); + expect(color1).to.equal(color3); + expect(color4).to.equal(ALL_COLOR_SCHEMES.bnbColors[1]); + }); +}); diff --git a/superset/assets/visualizations/chord.jsx b/superset/assets/visualizations/chord.jsx index c2b3c3498e7c4..dbb551ca36e4d 100644 --- a/superset/assets/visualizations/chord.jsx +++ b/superset/assets/visualizations/chord.jsx @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ import d3 from 'd3'; -import { category21 } from '../javascripts/modules/colors'; +import { getColorFromScheme } from '../javascripts/modules/colors'; import './chord.css'; function chordViz(slice, json) { @@ -60,7 +60,7 @@ function chordViz(slice, json) { const groupPath = group.append('path') .attr('id', (d, i) => 'group' + i) .attr('d', arc) - .style('fill', (d, i) => category21(nodes[i])); + .style('fill', (d, i) => getColorFromScheme(nodes[i], slice.formData.color_scheme)); // Add a text label. const groupText = group.append('text') @@ -84,7 +84,7 @@ function chordViz(slice, json) { .on('mouseover', (d) => { chord.classed('fade', p => p !== d); }) - .style('fill', d => category21(nodes[d.source.index])) + .style('fill', d => getColorFromScheme(nodes[d.source.index], slice.formData.color_scheme)) .attr('d', path); // Add an elaborate mouseover title for each chord. diff --git a/superset/assets/visualizations/histogram.js b/superset/assets/visualizations/histogram.js index 16af9c44ce513..b5bbf0951946b 100644 --- a/superset/assets/visualizations/histogram.js +++ b/superset/assets/visualizations/histogram.js @@ -1,5 +1,5 @@ import d3 from 'd3'; -import { category21 } from '../javascripts/modules/colors'; +import { getColorFromScheme } from '../javascripts/modules/colors'; require('./histogram.css'); @@ -76,7 +76,7 @@ function histogram(slice, payload) { .attr('x', d => x(d.x)) .attr('y', d => y(d.y)) .attr('height', d => y.range()[0] - y(d.y)) - .style('fill', d => category21(d.length)) + .style('fill', d => getColorFromScheme(d.length, slice.formData.color_scheme)) .order(); // Find maximum length to position the ticks on top of the bar correctly diff --git a/superset/assets/visualizations/nvd3_vis.js b/superset/assets/visualizations/nvd3_vis.js index 21342942cfd86..f9c03c638eaab 100644 --- a/superset/assets/visualizations/nvd3_vis.js +++ b/superset/assets/visualizations/nvd3_vis.js @@ -4,7 +4,7 @@ import throttle from 'lodash.throttle'; import d3 from 'd3'; import nv from 'nvd3'; -import { category21 } from '../javascripts/modules/colors'; +import { getColorFromScheme } from '../javascripts/modules/colors'; import { customizeToolTip, d3TimeFormatPreset, d3FormatPreset, tryNumify } from '../javascripts/modules/utils'; // CSS @@ -337,7 +337,7 @@ function nvd3Vis(slice, payload) { } if (vizType !== 'bullet') { - chart.color(d => category21(d[colorKey])); + chart.color(d => getColorFromScheme(d[colorKey], fd.color_scheme)); } if ((vizType === 'line' || vizType === 'area') && fd.rich_tooltip) { chart.useInteractiveGuideline(true); diff --git a/superset/assets/visualizations/sankey.js b/superset/assets/visualizations/sankey.js index 80e8980b8eaaf..2dcba6af34117 100644 --- a/superset/assets/visualizations/sankey.js +++ b/superset/assets/visualizations/sankey.js @@ -1,6 +1,6 @@ /* eslint-disable no-param-reassign */ import d3 from 'd3'; -import { category21 } from '../javascripts/modules/colors'; +import { getColorFromScheme } from '../javascripts/modules/colors'; import './sankey.css'; d3.sankey = require('d3-sankey').sankey; @@ -138,7 +138,7 @@ function sankeyVis(slice, payload) { .attr('width', sankey.nodeWidth()) .style('fill', function (d) { const name = d.name || 'N/A'; - d.color = category21(name.replace(/ .*/, '')); + d.color = getColorFromScheme(name.replace(/ .*/, ''), slice.formData.color_scheme); return d.color; }) .style('stroke', function (d) { diff --git a/superset/assets/visualizations/sunburst.js b/superset/assets/visualizations/sunburst.js index 34143c0be34e1..41859e3a2d87c 100644 --- a/superset/assets/visualizations/sunburst.js +++ b/superset/assets/visualizations/sunburst.js @@ -1,6 +1,6 @@ /* eslint-disable no-underscore-dangle, no-param-reassign */ import d3 from 'd3'; -import { category21 } from '../javascripts/modules/colors'; +import { getColorFromScheme } from '../javascripts/modules/colors'; import { wrapSvgText } from '../javascripts/modules/utils'; require('./sunburst.css'); @@ -110,7 +110,9 @@ function sunburstVis(slice, payload) { entering.append('svg:polygon') .attr('points', breadcrumbPoints) .style('fill', function (d) { - return colorByCategory ? category21(d.name) : colorScale(d.m2 / d.m1); + return colorByCategory ? + getColorFromScheme(d.name, slice.formData.color_scheme) : + colorScale(d.m2 / d.m1); }); entering.append('svg:text') @@ -119,7 +121,9 @@ function sunburstVis(slice, payload) { .attr('dy', '0.35em') .style('fill', function (d) { // Make text white or black based on the lightness of the background - const col = d3.hsl(colorByCategory ? category21(d.name) : colorScale(d.m2 / d.m1)); + const col = d3.hsl(colorByCategory ? + getColorFromScheme(d.name, slice.formData.color_scheme) : + colorScale(d.m2 / d.m1)); return col.l < 0.5 ? 'white' : 'black'; }) .attr('class', 'step-label') @@ -360,7 +364,9 @@ function sunburstVis(slice, payload) { }) .attr('d', arc) .attr('fill-rule', 'evenodd') - .style('fill', d => colorByCategory ? category21(d.name) : colorScale(d.m2 / d.m1)) + .style('fill', d => colorByCategory ? + getColorFromScheme(d.name, fd.color_scheme) : + colorScale(d.m2 / d.m1)) .style('opacity', 1) .on('mouseenter', mouseenter); diff --git a/superset/assets/visualizations/treemap.js b/superset/assets/visualizations/treemap.js index 2a5a9c3b3cf45..072582a97730c 100644 --- a/superset/assets/visualizations/treemap.js +++ b/superset/assets/visualizations/treemap.js @@ -1,6 +1,6 @@ /* eslint-disable no-shadow, no-param-reassign, no-underscore-dangle, no-use-before-define */ import d3 from 'd3'; -import { category21 } from '../javascripts/modules/colors'; +import { getColorFromScheme } from '../javascripts/modules/colors'; require('./treemap.css'); @@ -185,7 +185,7 @@ function treemap(slice, payload) { .text(function (d) { return formatNumber(d.value); }); t.call(text); g.selectAll('rect') - .style('fill', function (d) { return category21(d.name); }); + .style('fill', function (d) { return getColorFromScheme(d.name, formData.color_scheme); }); return g; }; diff --git a/superset/assets/visualizations/word_cloud.js b/superset/assets/visualizations/word_cloud.js index 5d445c7db7a6d..5ee0e0b3954d3 100644 --- a/superset/assets/visualizations/word_cloud.js +++ b/superset/assets/visualizations/word_cloud.js @@ -1,7 +1,7 @@ /* eslint-disable no-use-before-define */ import d3 from 'd3'; import cloudLayout from 'd3-cloud'; -import { category21 } from '../javascripts/modules/colors'; +import { getColorFromScheme } from '../javascripts/modules/colors'; function wordCloudChart(slice, payload) { const chart = d3.select(slice.selector); @@ -42,7 +42,7 @@ function wordCloudChart(slice, payload) { .append('text') .style('font-size', d => d.size + 'px') .style('font-family', 'Impact') - .style('fill', d => category21(d.text)) + .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);