From c4fb533088769208b0ff89e030d7e3496c6de4e7 Mon Sep 17 00:00:00 2001 From: Raphael Benitte Date: Sun, 17 Apr 2016 11:56:31 +0900 Subject: [PATCH] feat(colors): add smart declarative color management --- src/ColorUtils.js | 34 ++++++++ src/components/layouts/Pie.js | 9 +- src/components/layouts/PieColumnLegends.js | 17 +++- src/components/layouts/PieRadialLegends.js | 88 ++++++++++++++++++-- src/components/layouts/PieSliceLegends.js | 96 +++++++++++++++------- src/components/layouts/Stack.js | 8 +- 6 files changed, 208 insertions(+), 44 deletions(-) create mode 100644 src/ColorUtils.js diff --git a/src/ColorUtils.js b/src/ColorUtils.js new file mode 100644 index 000000000..36aa00608 --- /dev/null +++ b/src/ColorUtils.js @@ -0,0 +1,34 @@ +import d3 from 'd3'; + + +export const getColorGenerator = instruction => { + if (instruction === 'none') { + return 'none'; + } + + if (instruction === 'inherit') { + return d => d.data.color; + } + + const inheritMatches = instruction.match(/inherit:(darker|brighter)\(([0-9.]+)\)/); + if (inheritMatches) { + const method = inheritMatches[1]; + const amount = inheritMatches[2]; + + return d => d3.rgb(d.data.color)[method](parseFloat(amount)); + } + + throw new Error('Unable to determine color generator'); +}; + + +export const getColorStyleObject = (instruction, property) => { + const style = {}; + + const color = getColorGenerator(instruction); + if (color !== 'none') { + style[property] = color; + } + + return style; +}; \ No newline at end of file diff --git a/src/components/layouts/Pie.js b/src/components/layouts/Pie.js index f1db40a15..43c7affb1 100644 --- a/src/components/layouts/Pie.js +++ b/src/components/layouts/Pie.js @@ -43,6 +43,12 @@ class Pie extends Component { })); const color = d3.scale.category20(); + /* + const color = d3.scale.linear() + .domain([0, data.length / 2, data.length]) + .range(['#ba1300', '#c6482e', '#ff9068']) + ; + */ let slices = container.selectAll('.chart__layout__pie__slice'); const previousData = slices.data(); @@ -112,7 +118,6 @@ class Pie extends Component { } componentWillMount() { - console.log('Pie.render()'); const { children } = this.props; const legends = []; @@ -162,7 +167,7 @@ Pie.defaultProps = { startAngle: 0, endAngle: 360, padAngle: 0, - transitionDuration: 1000, + transitionDuration: 600, transitionEasing: 'cubic-out', innerRadius: 0 }; diff --git a/src/components/layouts/PieColumnLegends.js b/src/components/layouts/PieColumnLegends.js index a21f86d27..bf1eead73 100644 --- a/src/components/layouts/PieColumnLegends.js +++ b/src/components/layouts/PieColumnLegends.js @@ -2,12 +2,16 @@ import React, { Component, PropTypes } from 'react'; import invariant from 'invariant'; import d3 from 'd3'; import { midAngle, findNeighbor } from '../../ArcUtils'; +import { getColorStyleObject } from '../../ColorUtils'; class PieColumnLegends extends Component { static createLegendsFromReactElement(element) { const { props } = element; + const lineColorStyle = getColorStyleObject(props.lineColor, 'stroke'); + const textColorStyle = getColorStyleObject(props.textColor, 'fill'); + // Receive context from Parent Pie component return ({ element, arc, identity, pie, previousData, newData, radius }) => { @@ -21,11 +25,11 @@ class PieColumnLegends extends Component { let lines = element.selectAll('.line').data(newData, identity); lines.enter() .append('polyline') - .attr('stroke', '#fff') .attr('fill', 'none') .attr('class', 'line') ; lines + .style(lineColorStyle) .attr('points', d => { const p0 = arc.centroid(d); const p1 = outerArc.centroid(d); @@ -48,6 +52,7 @@ class PieColumnLegends extends Component { ; labels .text(labelFn) + .style(textColorStyle) .attr('text-anchor', d => { return midAngle(d) < Math.PI ? 'start' : 'end'; }) @@ -74,19 +79,23 @@ class PieColumnLegends extends Component { } } -const { number, func } = PropTypes; +const { number, func, any } = PropTypes; PieColumnLegends.propTypes = { labelFn: func, radiusOffset: number.isRequired, horizontalOffset: number.isRequired, - textOffset: number.isRequired + textOffset: number.isRequired, + lineColor: any.isRequired, + textColor: any.isRequired }; PieColumnLegends.defaultProps = { radiusOffset: 16, horizontalOffset: 30, - textOffset: 10 + textOffset: 10, + lineColor: 'none', + textColor: 'none' }; diff --git a/src/components/layouts/PieRadialLegends.js b/src/components/layouts/PieRadialLegends.js index 6bdbc2dc6..4fe04788f 100644 --- a/src/components/layouts/PieRadialLegends.js +++ b/src/components/layouts/PieRadialLegends.js @@ -2,25 +2,83 @@ import React, { Component, PropTypes } from 'react'; import invariant from 'invariant'; import d3 from 'd3'; import { midAngle, radiansToDegrees } from '../../ArcUtils'; +import { getColorGenerator } from '../../ColorUtils'; class PieRadialLegends extends Component { static createLegendsFromReactElement(element) { const { props } = element; - return ({ element, arc, keyProp, pie, data, radius }) => { + const color = getColorGenerator(props.textColor); - const labelFn = props.labelFn || (d => d.data[keyProp]); + return ({ element, arc, identity, pie, newData, radius }) => { + const labelFn = props.labelFn || identity; const outerArc = d3.svg.arc() .innerRadius(radius + props.radiusOffset) .outerRadius(radius + props.radiusOffset) ; - let labels = element.selectAll('.radial-label').data(data, d => d.data[keyProp]); + let labels = element.selectAll('.radial-label').data(newData, identity); + labels.enter().append('g') + .attr('class', 'radial-label') + .append('text') + .style('opacity', 0) + ; + + labels + .each(function (d) { + const el = d3.select(this); + + const angle = midAngle(d); + const angleOffset = angle < Math.PI ? -90 : 90; + + const styles = { opacity: 1 }; + if (color !== 'none') { + styles.fill = color(d); + } + + el.select('text') + .text(labelFn) + .attr('text-anchor', d => (midAngle(d) < Math.PI ? 'start' : 'end')) + .transition() + .duration(props.transitionDuration) + .ease(props.transitionEasing) + .style(styles) + .attr('transform', `translate(${radius + props.radiusOffset}, 0)`) + ; + }) + .transition() + .duration(props.transitionDuration) + .ease(props.transitionEasing) + .attr('transform', d => { + const angle = midAngle(d); + + return `rotate(${radiansToDegrees(angle)}, 0, 0)`; + }) + ; + labels.exit() + .each(function (d) { + const el = d3.select(this); + + el.select('text') + .transition() + .duration(props.transitionDuration) + .ease(props.transitionEasing) + .style('opacity', 0) + .attr('transform', `translate(${radius + props.radiusOffset + 50}, 0)`) + ; + }) + .transition() + .duration(0) + .delay(props.transitionDuration) + .remove() + ; + + + /* labels.enter() .append('text') - .attr('fill', '#fff') .attr('class', 'radial-label') ; labels @@ -28,6 +86,9 @@ class PieRadialLegends extends Component { .attr('text-anchor', d => { return midAngle(d) < Math.PI ? 'start' : 'end'; }) + .transition() + .duration(props.transitionDuration) + .ease(props.transitionEasing) .attr('transform', d => { const centroid = outerArc.centroid(d); const angle = midAngle(d); @@ -38,8 +99,13 @@ class PieRadialLegends extends Component { }) ; labels.exit() + .transition() + .duration(props.transitionDuration) + .ease(props.transitionEasing) + .style('opacity', 0) .remove() ; + */ }; } @@ -51,15 +117,21 @@ class PieRadialLegends extends Component { } } -const { number, func } = PropTypes; +const { number, string, func, any } = PropTypes; PieRadialLegends.propTypes = { - labelFn: func, - radiusOffset: number.isRequired + labelFn: func, + radiusOffset: number.isRequired, + transitionDuration: number.isRequired, + transitionEasing: string.isRequired, + textColor: any.isRequired }; PieRadialLegends.defaultProps = { - radiusOffset: 16 + radiusOffset: 16, + transitionDuration: 600, + transitionEasing: 'cubic-out', + textColor: 'none' }; diff --git a/src/components/layouts/PieSliceLegends.js b/src/components/layouts/PieSliceLegends.js index e11d917bb..2ff62168b 100644 --- a/src/components/layouts/PieSliceLegends.js +++ b/src/components/layouts/PieSliceLegends.js @@ -1,17 +1,22 @@ import React, { Component, PropTypes } from 'react'; import invariant from 'invariant'; +import _ from 'lodash'; import d3 from 'd3'; -import { midAngle, radiansToDegrees } from '../../ArcUtils'; +import { findNeighbor, midAngle, radiansToDegrees } from '../../ArcUtils'; +import { getColorStyleObject } from '../../ColorUtils'; class PieSliceLegends extends Component { static createLegendsFromReactElement(element) { const { props } = element; - return ({ element, keyProp, arc, data }) => { - let legends = element.selectAll('.slice-legend').data(data, d => d.data[keyProp]); + const badgeColorStyle = getColorStyleObject(props.badgeColor, 'fill'); + const textColorStyle = getColorStyleObject(props.textColor, 'fill'); - const labelFn = props.labelFn || (d => d.data[keyProp]); + return ({ element, identity, arc, previousData, newData }) => { + let legends = element.selectAll('.slice-legend').data(newData, identity); + + const labelFn = props.labelFn || identity; legends.enter() .append('g') @@ -21,23 +26,55 @@ class PieSliceLegends extends Component { return `translate(${centroid[0]}, ${centroid[1]})`; }) - .each(function (d) { - d3.select(this) - .append('circle') - .attr('fill', d3.rgb(d.data.color).darker(1).toString()) + .style('opacity', 0) + .each(function (d, i) { + this._current = findNeighbor(i, identity, previousData, newData) || _.assign({}, d, { endAngle: d.startAngle }); + const el = d3.select(this); + + el.append('circle') .attr('r', props.radius) ; - d3.select(this) - .append('text') - .attr('fill', d => d.data.color) + el.append('text') .attr('text-anchor', 'middle') ; }) ; legends - .attr('transform', d => { + .each(function (d) { + d3.select(this).select('circle') + .style(badgeColorStyle) + ; + + d3.select(this).select('text') + .style(textColorStyle) + .text(labelFn(d)) + ; + }) + .transition() + .duration(props.transitionDuration) + .ease(props.transitionEasing) + .style('opacity', 1) + .attrTween('transform', function (d) { + const interpolate = d3.interpolate({ + startAngle: this._current.startAngle, + endAngle: this._current.endAngle + }, d); + + return t => { + const angles = interpolate(t); + const centroid = arc.centroid(angles); + + let transform = `translate(${centroid[0]}, ${centroid[1]})`; + if (props.orient) { + const angle = midAngle(angles); + transform = `${transform} rotate(${radiansToDegrees(angle)}, 0, 0)`; + } + + return transform; + }; + /* const centroid = arc.centroid(d); let transform = `translate(${centroid[0]}, ${centroid[1]})`; @@ -47,20 +84,15 @@ class PieSliceLegends extends Component { } return transform; - }) - .each(function (d) { - d3.select(this).select('circle') - .attr('fill', d3.rgb(d.data.color).darker(1).toString()) - ; - - d3.select(this).select('text') - .attr('fill', d => d.data.color) - .text(labelFn(d)) - ; + */ }) ; legends.exit() + .transition() + .duration(props.transitionDuration) + .ease(props.transitionEasing) + .style('opacity', 0) .remove() ; }; @@ -74,17 +106,25 @@ class PieSliceLegends extends Component { } } -const { number, bool, func } = PropTypes; +const { number, string, bool, func, any } = PropTypes; PieSliceLegends.propTypes = { - labelFn: func, - radius: number.isRequired, - orient: bool.isRequired + labelFn: func, + radius: number.isRequired, + orient: bool.isRequired, + transitionDuration: number.isRequired, + transitionEasing: string.isRequired, + badgeColor: any.isRequired, + textColor: any.isRequired }; PieSliceLegends.defaultProps = { - radius: 12, - orient: true + radius: 12, + orient: true, + transitionDuration: 600, + transitionEasing: 'cubic-out', + badgeColor: 'none', + textColor: 'none' }; diff --git a/src/components/layouts/Stack.js b/src/components/layouts/Stack.js index a293d3486..b48c11a74 100644 --- a/src/components/layouts/Stack.js +++ b/src/components/layouts/Stack.js @@ -7,6 +7,7 @@ class Stack extends Component { const { layers, width, height, + interpolation, transitionDuration, transitionEasing } = nextProps; @@ -28,11 +29,12 @@ class Stack extends Component { ; const color = d3.scale.linear() - .range(["#aad", "#556"]) + .domain([0, 0.5, 1]) + .range(['#ba1300', '#c6482e', '#ff9068']) ; const area = d3.svg.area() - .interpolate('monotone') + .interpolate(interpolation) .x(d => xScale(d.x)) .y0(d => yScale(d.y0)) .y1(d => yScale(d.y0 + d.y)) @@ -98,6 +100,7 @@ Stack.propTypes = { layers: array.isRequired, keyProp: string.isRequired, valueProp: string.isRequired, + interpolation: PropTypes.string.isRequired, transitionDuration: number.isRequired, transitionEasing: string.isRequired }; @@ -106,6 +109,7 @@ Stack.defaultProps = { sort: null, keyProp: 'label', valueProp: 'value', + interpolation: 'monotone', transitionDuration: 1000, transitionEasing: 'cubic-out' };