diff --git a/src/EditorControls.js b/src/EditorControls.js index 4a6e47efc..e21ae8f33 100644 --- a/src/EditorControls.js +++ b/src/EditorControls.js @@ -7,6 +7,8 @@ import { shamefullyAdjustAxisRef, shamefullyAdjustGeo, shamefullyAddTableColumns, + shamefullyCreateSplitStyleProps, + shamefullyAdjustSplitStyleTargetContainers, } from './shame'; import {EDITOR_ACTIONS} from './lib/constants'; import isNumeric from 'fast-isnumeric'; @@ -66,16 +68,32 @@ class EditorControls extends Component { shamefullyClearAxisTypes(graphDiv, payload); shamefullyAdjustAxisRef(graphDiv, payload); shamefullyAddTableColumns(graphDiv, payload); + shamefullyAdjustSplitStyleTargetContainers(graphDiv, payload); for (let i = 0; i < payload.traceIndexes.length; i++) { for (const attr in payload.update) { const traceIndex = payload.traceIndexes[i]; - const prop = nestedProperty(graphDiv.data[traceIndex], attr); + const splitTraceGroup = payload.splitTraceGroup + ? payload.splitTraceGroup.toString() + : null; + + let props = [nestedProperty(graphDiv.data[traceIndex], attr)]; const value = payload.update[attr]; - if (value !== void 0) { - prop.set(value); + if (splitTraceGroup) { + props = shamefullyCreateSplitStyleProps( + graphDiv, + attr, + traceIndex, + splitTraceGroup + ); } + + props.forEach(p => { + if (value !== void 0) { + p.set(value); + } + }); } } diff --git a/src/components/containers/TraceAccordion.js b/src/components/containers/TraceAccordion.js index 9b7f863fe..faad5d797 100644 --- a/src/components/containers/TraceAccordion.js +++ b/src/components/containers/TraceAccordion.js @@ -10,38 +10,109 @@ import {Tab, Tabs, TabList, TabPanel} from 'react-tabs'; const TraceFold = connectTraceToPlot(PlotlyFold); class TraceAccordion extends Component { - render() { - const {data = [], localize: _} = this.context; - const { - canAdd, - canGroup, - children, - messageIfEmptyFold, - excludeFits, - } = this.props; + constructor(props, context) { + super(props, context); + this.setLocals(props, context); + } + componentWillReceiveProps(nextProps, nextContext) { + this.setLocals(nextProps, nextContext); + } + + setLocals(props, context) { // we don't want to include analysis transforms when we're in the create panel - const filteredData = data.filter(t => { - if (excludeFits) { + const base = props.canGroup ? context.fullData : context.data; + + this.filteredTracesFullDataPositions = []; + this.filteredTraces = base.filter((t, i) => { + if (props.excludeFits) { return !(t.transforms && t.transforms.every(tr => tr.type === 'fit')); } + this.filteredTracesFullDataPositions.push(i); return true; }); + } - const individualTraces = - filteredData.length && - filteredData.map((d, i) => { - return ( - - {children} - - ); - }); + renderGroupedTraceFolds() { + if (!this.filteredTraces.length || this.filteredTraces.length < 2) { + return null; + } + + const dataArrayPositionsByTraceType = {}; + const fullDataArrayPositionsByTraceType = {}; + + this.filteredTraces.forEach((trace, index) => { + const traceType = plotlyTraceToCustomTrace(trace); + if (!dataArrayPositionsByTraceType[traceType]) { + dataArrayPositionsByTraceType[traceType] = []; + } + + if (!fullDataArrayPositionsByTraceType[traceType]) { + fullDataArrayPositionsByTraceType[traceType] = []; + } + + dataArrayPositionsByTraceType[traceType].push(trace.index); + fullDataArrayPositionsByTraceType[traceType].push( + this.filteredTracesFullDataPositions[index] + ); + }); + + return Object.keys(fullDataArrayPositionsByTraceType).map((type, index) => { + return ( + + {this.props.children} + + ); + }); + } + + renderUngroupedTraceFolds() { + if (!this.filteredTraces.length) { + return null; + } + + return this.filteredTraces.map((d, i) => { + return ( + + {this.props.children} + + ); + }); + } + + renderTraceFolds() { + if (!this.filteredTraces.length) { + return null; + } + + return this.filteredTraces.map((d, i) => { + return ( + + {this.props.children} + + ); + }); + } + + render() { + const {canAdd, canGroup} = this.props; + const _ = this.context.localize; if (canAdd) { const addAction = { @@ -54,58 +125,44 @@ class TraceAccordion extends Component { } }, }; + return ( - {individualTraces ? individualTraces : null} + {this.renderTraceFolds()} ); } - const tracesByGroup = filteredData.reduce((allTraces, nextTrace, index) => { - const traceType = plotlyTraceToCustomTrace(nextTrace); - if (!allTraces[traceType]) { - allTraces[traceType] = []; - } - allTraces[traceType].push(index); - return allTraces; - }, {}); - const groupedTraces = Object.keys(tracesByGroup).map((traceType, index) => { - return ( - - {this.props.children} - - ); - }); + if (canGroup) { + if (this.filteredTraces.length === 1) { + return ( + + {this.renderUngroupedTraceFolds()} + + ); + } - if (canGroup && filteredData.length > 1 && groupedTraces.length > 0) { - return ( - - - - {_('Individually')} - {_('By Type')} - - - - {individualTraces ? individualTraces : null} - - - - {groupedTraces ? groupedTraces : null} - - - - ); + if (this.filteredTraces.length > 1) { + return ( + + + + {_('Individually')} + {_('By Type')} + + + {this.renderUngroupedTraceFolds()} + + + {this.renderGroupedTraceFolds()} + + + + ); + } } - return ( - - {individualTraces ? individualTraces : null} - - ); + + return {this.renderTraceFolds()}; } } diff --git a/src/components/containers/TransformAccordion.js b/src/components/containers/TransformAccordion.js index f1129c120..6211bc54e 100644 --- a/src/components/containers/TransformAccordion.js +++ b/src/components/containers/TransformAccordion.js @@ -83,9 +83,17 @@ class TransformAccordion extends Component { )); + // cannot have 2 Split transforms on one trace: + // https://github.com/plotly/plotly.js/issues/1742 + const addActionOptions = + container.transforms && + container.transforms.some(t => t.type === 'groupby') + ? transformTypes.filter(t => t.type !== 'groupby') + : transformTypes; + const addAction = { label: _('Transform'), - handler: transformTypes.map(({label, type}) => { + handler: addActionOptions.map(({label, type}) => { return { label, handler: context => { @@ -104,6 +112,10 @@ class TransformAccordion extends Component { payload.groups = null; } + if (type === 'groupby') { + payload.styles = []; + } + updateContainer({[key]: payload}); } }, diff --git a/src/components/fields/DataSelector.js b/src/components/fields/DataSelector.js index 57a8e551e..29e928477 100644 --- a/src/components/fields/DataSelector.js +++ b/src/components/fields/DataSelector.js @@ -49,7 +49,7 @@ export class UnconnectedDataSelector extends Component { Array.isArray(this.fullValue); } - this.hasData = props.attr in props.container; + this.hasData = props.container ? props.attr in props.container : false; } updatePlot(value) { @@ -83,6 +83,7 @@ export class UnconnectedDataSelector extends Component { : null, } ); + this.props.updateContainer(update); } @@ -136,6 +137,7 @@ UnconnectedDataSelector.contextTypes = { toSrc: PropTypes.func.isRequired, fromSrc: PropTypes.func.isRequired, }), + container: PropTypes.object, }; function modifyPlotProps(props, context, plotProps) { diff --git a/src/components/fields/MarkerColor.js b/src/components/fields/MarkerColor.js index b1e2018a4..30fd0a5b2 100644 --- a/src/components/fields/MarkerColor.js +++ b/src/components/fields/MarkerColor.js @@ -91,8 +91,10 @@ class UnconnectedMarkerColor extends Component { (Array.isArray(this.props.fullValue) && this.props.fullValue.includes(MULTI_VALUED)) || (this.props.container.marker && + this.props.container.marker.colorscale && this.props.container.marker.colorscale === MULTI_VALUED) || (this.props.container.marker && + this.props.container.marker.colorsrc && this.props.container.marker.colorsrc === MULTI_VALUED) || (this.props.container.marker && this.props.container.marker.color && @@ -130,12 +132,12 @@ class UnconnectedMarkerColor extends Component { renderVariableControls() { const multiValued = - (this.props.container && - this.props.container.marker && - (this.props.container.marker.colorscale && - this.props.container.marker.colorscale === MULTI_VALUED)) || - (this.props.container.marker.colorsrc && - this.props.container.marker.colorsrc === MULTI_VALUED); + this.props.container && + this.props.container.marker && + ((this.props.container.marker.colorscale && + this.props.container.marker.colorscale === MULTI_VALUED) || + (this.props.container.marker.colorsrc && + this.props.container.marker.colorsrc === MULTI_VALUED)); return ( @@ -154,72 +156,87 @@ class UnconnectedMarkerColor extends Component { render() { const {attr} = this.props; - const {localize: _} = this.context; - const {type} = this.state; - const options = [ - {label: _('Constant'), value: 'constant'}, - {label: _('Variable'), value: 'variable'}, - ]; + const {localize: _, container} = this.context; - return ( - - - - - - {!type ? null : ( - - {type === 'constant' - ? _('All points in a trace are colored in the same color.') - : _('Each point in a trace is colored according to data.')} - - )} + // TO DO: https://github.com/plotly/react-chart-editor/issues/654 + const noSplitsPresent = + container && + (!container.transforms || + !container.transforms.filter(t => t.type === 'groupby').length); + + if (noSplitsPresent) { + const {type} = this.state; + const options = [ + {label: _('Constant'), value: 'constant'}, + {label: _('Variable'), value: 'variable'}, + ]; + + return ( + + + + + + {!type ? null : ( + + {type === 'constant' + ? _('All points in a trace are colored in the same color.') + : _('Each point in a trace is colored according to data.')} + + )} + + + {!type + ? null + : type === 'constant' + ? this.renderConstantControls() + : this.renderVariableControls()} + {type === 'constant' ? null : ( + + + + + + + + + )} + + ); + } - {!type - ? null - : type === 'constant' - ? this.renderConstantControls() - : this.renderVariableControls()} - - {type === 'constant' ? null : ( - - - - - - - - - )} - + return ( + + {this.renderConstantControls()} + ); } } @@ -234,6 +251,7 @@ UnconnectedMarkerColor.contextTypes = { localize: PropTypes.func, updateContainer: PropTypes.func, traceIndexes: PropTypes.array, + container: PropTypes.object, }; export default connectToContainer(UnconnectedMarkerColor); diff --git a/src/components/fields/MultiColorPicker.js b/src/components/fields/MultiColorPicker.js index 9d60f0899..9fb1aee0c 100644 --- a/src/components/fields/MultiColorPicker.js +++ b/src/components/fields/MultiColorPicker.js @@ -1,10 +1,11 @@ -import React, {Component} from 'react'; import Color from './Color'; import Colorscale from './Colorscale'; -import Info from './Info'; import Field from './Field'; -import RadioBlocks from '../widgets/RadioBlocks'; +import Info from './Info'; import PropTypes from 'prop-types'; +import RadioBlocks from '../widgets/RadioBlocks'; +import React, {Component} from 'react'; +import nestedProperty from 'plotly.js/src/lib/nested_property'; import {adjustColorscale, connectToContainer} from 'lib'; class UnconnectedMultiColorPicker extends Component { @@ -26,7 +27,7 @@ class UnconnectedMultiColorPicker extends Component { } setColors(colorscale, colorscaleType) { - const numberOfTraces = this.context.traceIndexes.length; + const numberOfTraces = this.props.tracesToColor.length; const colors = colorscale.map(c => c[1]); let adjustedColors = colors; @@ -133,6 +134,7 @@ UnconnectedMultiColorPicker.propTypes = { onConstantColorOptionChange: PropTypes.func, messageKeyWordSingle: PropTypes.string, messageKeyWordPlural: PropTypes.string, + tracesToColor: PropTypes.array, ...Field.propTypes, }; @@ -146,22 +148,32 @@ UnconnectedMultiColorPicker.contextTypes = { export default connectToContainer(UnconnectedMultiColorPicker, { modifyPlotProps(props, context, plotProps) { if (plotProps.isVisible) { - plotProps.fullValue = context.traceIndexes - .map(index => { - const trace = context.fullData.filter( - trace => trace.index === index - )[0]; - - const properties = props.attr.split('.'); - let value = trace; - - properties.forEach(prop => { - value = value[prop]; - }); - - return value; - }) - .map(c => [0, c]); + const colors = []; + let tracesToColor = []; + const dedupedTraceIndexes = []; + + context.traceIndexes.forEach(i => { + if (!dedupedTraceIndexes.includes(i)) { + dedupedTraceIndexes.push(i); + } + }); + + dedupedTraceIndexes.forEach(traceIndex => { + const traces = context.fullData.filter( + trace => trace.index === traceIndex + ); + tracesToColor = tracesToColor.concat(traces); + + traces.forEach(t => { + const value = nestedProperty(t, props.attr).get(); + if (value) { + colors.push(value); + } + }); + }); + + plotProps.tracesToColor = tracesToColor; + plotProps.fullValue = colors.map(c => [0, c]); } }, }); diff --git a/src/default_panels/GraphSubplotsPanel.js b/src/default_panels/GraphSubplotsPanel.js index 04ed56e41..3aa101da5 100644 --- a/src/default_panels/GraphSubplotsPanel.js +++ b/src/default_panels/GraphSubplotsPanel.js @@ -17,7 +17,7 @@ import { import {TRACE_TO_AXIS} from '../lib/constants'; const GraphSubplotsPanel = (props, {localize: _}) => ( - + diff --git a/src/lib/connectTraceToPlot.js b/src/lib/connectTraceToPlot.js index 8cff167f4..15bd8952b 100644 --- a/src/lib/connectTraceToPlot.js +++ b/src/lib/connectTraceToPlot.js @@ -24,15 +24,13 @@ export default function connectTraceToPlot(WrappedComponent) { this.setLocals(nextProps, nextContext); } - setLocals(props, context) { - const {traceIndexes} = props; - const {data, fullData, plotly} = context; - - const trace = traceIndexes.length > 0 ? data[traceIndexes[0]] : {}; - + getFullTraceFromDataIndex(trace, context) { let fullTrace = {}; - for (let i = 0; i < fullData.length; i++) { - if (traceIndexes[0] === fullData[i]._fullInput.index) { + + for (let i = 0; i < context.fullData.length; i++) { + if ( + this.props.traceIndexes[0] === context.fullData[i]._fullInput.index + ) { /* * Fit transforms are custom transforms in our custom plotly.js bundle, * they are different from others as they create an extra trace in the @@ -52,15 +50,26 @@ export default function connectTraceToPlot(WrappedComponent) { trace.transforms && trace.transforms.every(t => t.type === 'fit') ) { - fullData[i]._fullInput = fullData[i]; + context.fullData[i]._fullInput = context.fullData[i]; } - fullTrace = fullData[i]._fullInput; - + fullTrace = context.fullData[i]._fullInput; break; } } + return fullTrace; + } + + setLocals(props, context) { + const {traceIndexes, fullDataArrayPosition} = props; + const {data, fullData, plotly} = context; + + const trace = data[traceIndexes[0]]; + const fullTrace = fullDataArrayPosition + ? fullData[fullDataArrayPosition[0]] + : this.getFullTraceFromDataIndex(trace, context); + this.childContext = { getValObject: attr => !plotly @@ -110,6 +119,12 @@ export default function connectTraceToPlot(WrappedComponent) { updateTrace(update) { if (this.context.onUpdate) { + const splitTraceGroup = this.props.fullDataArrayPosition + ? this.props.fullDataArrayPosition.map( + p => this.context.fullData[p]._group + ) + : null; + if (Array.isArray(update)) { update.forEach((u, i) => { this.context.onUpdate({ @@ -117,6 +132,18 @@ export default function connectTraceToPlot(WrappedComponent) { payload: { update: u, traceIndexes: [this.props.traceIndexes[i]], + splitTraceGroup: splitTraceGroup ? splitTraceGroup[i] : null, + }, + }); + }); + } else if (splitTraceGroup) { + this.props.traceIndexes.forEach((t, i) => { + this.context.onUpdate({ + type: EDITOR_ACTIONS.UPDATE_TRACES, + payload: { + update, + traceIndexes: [this.props.traceIndexes[i]], + splitTraceGroup: splitTraceGroup ? splitTraceGroup[i] : null, }, }); }); @@ -205,6 +232,7 @@ export default function connectTraceToPlot(WrappedComponent) { TraceConnectedComponent.propTypes = { traceIndexes: PropTypes.arrayOf(PropTypes.number).isRequired, + fullDataArrayPosition: PropTypes.arrayOf(PropTypes.number), }; TraceConnectedComponent.contextTypes = { diff --git a/src/lib/customTraceType.js b/src/lib/customTraceType.js index def6ceaee..5b1e3017d 100644 --- a/src/lib/customTraceType.js +++ b/src/lib/customTraceType.js @@ -1,6 +1,12 @@ import {COLORS} from 'lib/constants'; export function plotlyTraceToCustomTrace(trace) { + if (typeof trace !== 'object') { + throw new Error( + `trace provided to plotlyTraceToCustomTrace function should be an object, received ${typeof trace}` + ); + } + const gl = 'gl'; const type = trace.type ? trace.type.endsWith(gl) diff --git a/src/lib/multiValues.js b/src/lib/multiValues.js index 2f702d9c4..ab0be3898 100644 --- a/src/lib/multiValues.js +++ b/src/lib/multiValues.js @@ -22,7 +22,7 @@ function setMultiValuedContainer(intoObj, fromObj, key, config = {}) { // don't merge private attrs if ( - (typeof key === 'string' && key.charAt(0) === '_') || + (typeof key === 'string' && key.charAt(0) === '_' && key !== '_group') || typeof intoVal === 'function' || key === 'module' ) { diff --git a/src/shame.js b/src/shame.js index 1c1c0e397..1608d0db8 100644 --- a/src/shame.js +++ b/src/shame.js @@ -112,3 +112,93 @@ export const shamefullyAddTableColumns = (graphDiv, {traceIndexes, update}) => { update['header.values'] = null; } }; + +export const shamefullyAdjustSplitStyleTargetContainers = ( + graphDiv, + {traceIndexes, update} +) => { + for (const attr in update) { + if (attr && attr.startsWith('transforms') && attr.endsWith('groups')) { + const transformIndex = parseInt(attr.split('[')[1], 10); + const transform = + graphDiv.data[traceIndexes[0]].transforms[transformIndex]; + + if (transform && transform.type === 'groupby' && transform.styles) { + // Create style containers for all groups + if (!transform.styles.length && update[attr]) { + const dedupedGroups = []; + update[attr].forEach(group => { + if (!dedupedGroups.includes(group)) { + dedupedGroups.push(group); + } + }); + + const styles = dedupedGroups.map(groupEl => ({ + target: groupEl, + value: {}, + })); + + update[`transforms[${transformIndex}].styles`] = styles; + } + + // When clearing the data selector of groupby transforms, we want to clear + // all the styles we've added + if (transform.styles.length && !update[attr]) { + update[`transforms[${transformIndex}].styles`] = []; + } + } + } + } +}; + +export const shamefullyCreateSplitStyleProps = ( + graphDiv, + attr, + traceIndex, + splitTraceGroup +) => { + if (!Array.isArray(splitTraceGroup)) { + splitTraceGroup = [splitTraceGroup]; // eslint-disable-line + } + + let indexOfSplitTransform = null; + + graphDiv.data[traceIndex].transforms.forEach((t, i) => { + if (t.type === 'groupby') { + indexOfSplitTransform = i; + } + }); + + function getProp(group) { + let indexOfStyleObject = null; + + graphDiv.data[traceIndex].transforms[indexOfSplitTransform].styles.forEach( + (s, i) => { + if (s.target.toString() === group) { + indexOfStyleObject = i; + } + } + ); + + let path = + graphDiv.data[traceIndex].transforms[indexOfSplitTransform].styles[ + indexOfStyleObject + ].value; + + attr.split('.').forEach(p => { + if (!path[p]) { + path[p] = {}; + } + path = path[p]; + }); + + return nestedProperty( + graphDiv.data[traceIndex].transforms[indexOfSplitTransform].styles[ + indexOfStyleObject + ].value, + attr + ); + } + + return splitTraceGroup.map(g => getProp(g)); +};