diff --git a/src/BubbleChart/BubbleChart.js b/src/BubbleChart/BubbleChart.js new file mode 100644 index 00000000..48b371e3 --- /dev/null +++ b/src/BubbleChart/BubbleChart.js @@ -0,0 +1,63 @@ +import React, { Component, PropTypes } from 'react'; +import BubbleContainer from './components/BubbleContainer'; +import Bubbles from './components/Bubbles'; +import GroupingPicker from './components/GroupingPicker'; +import CategoryTitles from './components/CategoryTitles'; + +import { createNodes, width, height, center, category } from './utils'; + +export default class BubleAreaChart extends Component { + + state = { + data: [], + grouping: 'all', + } + + componentWillMount() { + this.setState({ + data: createNodes(this.props.data), + }); + } + + onGroupingChanged = (newGrouping) => { + this.setState({ + grouping: newGrouping, + }); + }; + + render() { + const { data, grouping } = this.state; + return ( +
+ +
+ + + { + grouping === 'category1' && + + } + { + grouping === 'category2' && + + } + +
+
+ ); + } +} + +BubleAreaChart.propTypes = { + data: PropTypes.arrayOf(PropTypes.object.isRequired), + colors: PropTypes.arrayOf(PropTypes.string.isRequired), +}; diff --git a/src/BubbleChart/components/BubbleContainer.js b/src/BubbleChart/components/BubbleContainer.js new file mode 100644 index 00000000..e28d7d82 --- /dev/null +++ b/src/BubbleChart/components/BubbleContainer.js @@ -0,0 +1,16 @@ +import React, { PropTypes } from 'react'; + +const BubbleContainer = ({ width, height, children }) => + + { + children + } + ; + +BubbleContainer.propTypes = { + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + children: PropTypes.node, +}; + +export default BubbleContainer; diff --git a/src/BubbleChart/components/Bubbles.js b/src/BubbleChart/components/Bubbles.js new file mode 100644 index 00000000..317fb5c1 --- /dev/null +++ b/src/BubbleChart/components/Bubbles.js @@ -0,0 +1,146 @@ +import React, { PropTypes } from 'react'; +import * as d3 from 'd3'; +import tooltip from './Tooltip'; +import styles from './Tooltip.css'; +import { checkProps } from '../utils'; + +export default class Bubbles extends React.Component { + constructor(props) { + super(props); + const { forceStrength, center } = props; + this.simulation = d3.forceSimulation() + .velocityDecay(0.2) + .force('x', d3.forceX().strength(forceStrength).x(center.x)) + .force('y', d3.forceY().strength(forceStrength).y(center.y)) + .force('charge', d3.forceManyBody().strength(this.charge.bind(this))) + .on('tick', this.ticked.bind(this)) + .stop(); + } + + state = { + g: null, + } + + componentWillReceiveProps(nextProps) { + if (nextProps.data !== this.props.data) { + this.renderBubbles(nextProps.data); + } else { + checkProps(nextProps, this.props, this.simulation, this.resetBubbles); + } + } + + shouldComponentUpdate() { + // we will handle moving the nodes on our own with d3.js + // make React ignore this component + return false; + } + + onRef = (ref) => { + this.setState({ g: d3.select(ref) }, () => this.renderBubbles(this.props.data)); + } + + ticked() { + this.state.g.selectAll('.bubble') + .attr('cx', d => d.x) + .attr('cy', d => d.y); + } + + charge(d) { + return -this.props.forceStrength * (d.radius ** 2.0); + } + + resetBubbles = () => { + const { forceStrength, center } = this.props; + this.simulation.force('x', d3.forceX().strength(forceStrength).x(center.x)) + .force('y', d3.forceY().strength(forceStrength).y(center.y)); + this.simulation.alpha(1).restart(); + } + + renderBubbles(data) { + const bubbles = this.state.g.selectAll('.bubble').data(data, d => d.id); + + // Exit + bubbles.exit().remove(); + + // Enter + const bubblesE = bubbles.enter() + .append('circle') + .classed('bubble', true) + .attr('r', 0) + .attr('cx', d => d.x) + .attr('cy', d => d.y) + .attr('fill', d => d.color) + .attr('stroke', d => d3.rgb(d.color).darker()) + .attr('stroke-width', 2) + .on('mouseover', showDetail) // eslint-disable-line + .on('mouseout', hideDetail) // eslint-disable-line + + bubblesE.transition().duration(2000).attr('r', d => d.radius).on('end', () => { + this.simulation.nodes(data) + .alpha(1) + .restart(); + }); + } + + render() { + return ( + + ); + } +} + +Bubbles.propTypes = { + center: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }), + forceStrength: PropTypes.number.isRequired, + data: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.number.isRequired, + id: PropTypes.number.isRequired, + radius: PropTypes.number.isRequired, + value: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + })), +}; + +/* +* Function called on mouseover to display the +* details of a bubble in the tooltip. +*/ +export function showDetail(d) { + // change outline to indicate hover state. + d3.select(this).attr('stroke', 'black'); + + const content = `Title: + + ${d.name} +
` + + + `Amount: + + ${d.value} +
` + + + `Category: + + ${d.category} +
` + + + `Spending: + + ${d.spending} +
`; + tooltip.showTooltip(content, d3.event); +} + +/* +* Hides tooltip +*/ +export function hideDetail() { + // reset outline + d3.select(this) + .attr('stroke', d => d3.rgb(d.color).darker()); + + tooltip.hideTooltip(); +} diff --git a/src/BubbleChart/components/CategoryTitles.js b/src/BubbleChart/components/CategoryTitles.js new file mode 100644 index 00000000..3f94c5d0 --- /dev/null +++ b/src/BubbleChart/components/CategoryTitles.js @@ -0,0 +1,29 @@ +import React, { PropTypes } from 'react'; + +const CategoryTitles = ({ categoryCenters }) => + + { + Object.keys(categoryCenters).map(category => + + { + category + } + ) + } + ; + +CategoryTitles.propTypes = { + categoryCenters: PropTypes.objectOf(PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + }).isRequired).isRequired, +}; + +export default CategoryTitles; diff --git a/src/BubbleChart/components/GroupingPicker.css b/src/BubbleChart/components/GroupingPicker.css new file mode 100644 index 00000000..207d289d --- /dev/null +++ b/src/BubbleChart/components/GroupingPicker.css @@ -0,0 +1,22 @@ +.GroupingPicker { + display: flex; + justify-content: flex-start; + padding: 10px; +} + +.button { + min-width: 130px; + padding: 4px 5px; + cursor: pointer; + text-align: center; + font-size: 13px; + background: white; + border: 1px solid #e0e0e0; + text-decoration: none; + margin: 0 5px; +} + +.active { + background: black; + color: white; +} diff --git a/src/BubbleChart/components/GroupingPicker.js b/src/BubbleChart/components/GroupingPicker.js new file mode 100644 index 00000000..8ef62e47 --- /dev/null +++ b/src/BubbleChart/components/GroupingPicker.js @@ -0,0 +1,24 @@ +import React, { PropTypes } from 'react'; +import styles from './GroupingPicker.css'; + +export default class GroupingPicker extends React.Component { + onBtnClick = (event) => { + this.props.onChanged(event.target.name); + } + render() { + const { active } = this.props; + + return ( +
+ + + +
+ ); + } +} + +GroupingPicker.propTypes = { + onChanged: PropTypes.func.isRequired, + active: PropTypes.oneOf(['all', 'category1', 'category2']).isRequired, +}; diff --git a/src/BubbleChart/components/Tooltip.css b/src/BubbleChart/components/Tooltip.css new file mode 100644 index 00000000..b3e29624 --- /dev/null +++ b/src/BubbleChart/components/Tooltip.css @@ -0,0 +1,21 @@ +.tooltip { + position: absolute; + -moz-border-radius:5px; + border-radius: 5px; + border: 2px solid #000; + background: #fff; + opacity: .9; + color: black; + padding: 10px; + width: 300px; + font-size: 12px; + z-index: 10; +} + +.tooltip .title { + font-size: 13px; +} + +.tooltip .name { + font-weight:bold; +} diff --git a/src/BubbleChart/components/Tooltip.js b/src/BubbleChart/components/Tooltip.js new file mode 100644 index 00000000..d6b0bc0e --- /dev/null +++ b/src/BubbleChart/components/Tooltip.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +import * as d3 from 'd3'; +import styles from './Tooltip.css'; + +/* + * Creates tooltip with provided id that + * floats on top of visualization. + * Most styling is expected to come from CSS + * so check out bubble_chart.css for more details. + */ +const floatingTooltip = (tooltipId, width) => { + + // Local variable to hold tooltip div for + // manipulation in other functions. + const tt = d3.select('body') + .append('div') + .attr('class', styles.tooltip) + .attr('id', tooltipId) + .style('pointer-events', 'none'); + + /* + * Display tooltip with provided content. + * content is expected to be HTML string. + * event is d3.event for positioning. + */ + const showTooltip = (content, event) => { + tt.style('opacity', 1.0) + .html(content); + + updatePosition(event); + } + + /* + * Hide the tooltip div. + */ + const hideTooltip = () => { + tt.style('opacity', 0.0); + } + + /* + * Figure out where to place the tooltip + * based on d3 mouse event. + */ + const updatePosition = (event) => { + const xOffset = 20; + const yOffset = 10; + + const ttw = tt.style('width'); + const tth = tt.style('height'); + + var wscrY = window.scrollY; + var wscrX = window.scrollX; + + const curX = (document.all) ? event.clientX + wscrX : event.pageX; + const curY = (document.all) ? event.clientY + wscrY : event.pageY; + + let ttleft = ((curX - wscrX + xOffset * 2 + ttw) > window.innerWidth) ? + curX - ttw - xOffset * 2 : curX + xOffset; + + if (ttleft < wscrX + xOffset) { + ttleft = wscrX + xOffset; + } + + let tttop = ((curY - wscrY + yOffset * 2 + tth) > window.innerHeight) ? + curY - tth - yOffset * 2 : curY + yOffset; + + if (tttop < wscrY + yOffset) { + tttop = curY + yOffset; + } + + tt + .style('top', '100px') + .style('left', '100px') + } + + // Initially it is hidden. + hideTooltip(); + + return { + showTooltip, + hideTooltip, + updatePosition, + }; +}; + +const tooltip = floatingTooltip('myTooltip', 240); +export default tooltip; diff --git a/src/BubbleChart/utils.js b/src/BubbleChart/utils.js new file mode 100644 index 00000000..583a8ae3 --- /dev/null +++ b/src/BubbleChart/utils.js @@ -0,0 +1,91 @@ +import * as d3 from 'd3'; + +// constants +export const width = 960; +export const height = 640; +export const center = { x: width / 2, y: height / 2 }; + +// category titles and center position +export const category = { + 1: { + One: { x: width / 4, y: height / 2 }, + Two: { x: width / 2, y: height / 2 }, + Three: { x: (3 / 4) * width, y: height / 2 }, + }, + 2: { + Manditory: { x: width / 3, y: height / 2 }, + Discretionary: { x: width / 1.5, y: height / 2 }, + }, +}; + + +// create nodes +/* + * This data manipulation function takes the raw data from + * the CSV file and converts it into an array of node objects. + * Each node will store data and visualization values to visualize + * a bubble. + * + * rawData is expected to be an array of data objects, read in from + * one of d3's loading functions like d3.csv. + * + * This function returns the new node array, with a node in that + * array for each element in the rawData input. + */ + +export const createNodes = (rawData) => { + // Use the max total_amount in the data as the max in the scale's domain + // note we have to ensure the total_amount is a number. + const maxAmount = d3.max(rawData, d => +d.total_amount); + + // Sizes bubbles based on area. + // @v4: new flattened scale names. + const radiusScale = d3.scalePow() + .exponent(0.5) + .range([2, 85]) + .domain([0, maxAmount]); + + // Use map() to convert raw data into node data. + const myNodes = rawData.map(d => ({ + id: d.id, + radius: radiusScale(+d.total_amount), + value: +d.total_amount, + name: d.bureau_title, + group: d.group, + color: d.color, + category1: d.category1, + category2: d.category2, + x: Math.random() * 900, + y: Math.random() * 800, + })); + + // sort them descending to prevent occlusion of smaller nodes. + myNodes.sort((a, b) => b.value - a.value); + + return myNodes; +}; + +// regroup bubbles to active category +export const checkProps = (nextProps, props, simulation, resetBubbles) => { + let regroupBubbles = ''; + if (nextProps.groupByCategory1 === true) { + regroupBubbles = (function Cat1() { + const { forceStrength, categoryCenters1 } = props; + simulation.force('x', d3.forceX().strength(forceStrength).x(d => categoryCenters1[d.category1].x)) + .force('y', d3.forceY().strength(forceStrength).y(d => categoryCenters1[d.category1].y)); + simulation.alpha(1).restart(); + }()); + } else if (nextProps.groupByCategory2 === true) { + regroupBubbles = (function Cat2() { + const { forceStrength, categoryCenters2 } = props; + simulation.force('x', d3.forceX().strength(forceStrength).x(d => categoryCenters2[d.category2].x)) + .force('y', d3.forceY().strength(forceStrength).y(d => categoryCenters2[d.category2].y)); + simulation.alpha(1).restart(); + }()); + } + if (regroupBubbles === '') { + return resetBubbles(); + } + + return regroupBubbles; +}; diff --git a/src/index.js b/src/index.js index 7d1986ca..21517824 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,6 @@ export AreaChart from './AreaChart/AreaChart'; export BarChart from './BarChart/BarChart'; +export BubbleChart from './BubbleChart/BubbleChart'; export Sankey from './Sankey/Sankey'; export Button from './Button/Button'; export StoryCard from './StoryCard/StoryCard'; diff --git a/stories/BubbleChart.story.js b/stories/BubbleChart.story.js new file mode 100644 index 00000000..62befabb --- /dev/null +++ b/stories/BubbleChart.story.js @@ -0,0 +1,91 @@ +import React from 'react'; +import { storiesOf } from '@kadira/storybook'; +import { BubbleChart } from '../src'; +import { colors } from './shared'; + +const displayName = BubbleChart.displayName || 'BubbleChart'; +const title = 'Simple usage'; +const description = ` + This is some basic usage with the BubbleChart.`; + +const data = [ + { bureau_title: 'Bureau of Environmental Services', + id: 1, + total_amount: 7468965, + group: 'low', + color: colors[2], + category1: 'One', + category2: 'Manditory' }, + { bureau_title: 'City Budget Office', + id: 2, + total_amount: 115497, + group: 'medium', + color: colors[2], + category1: 'One', + category2: 'Manditory' }, + { bureau_title: 'Office of Management & Finance', + id: 3, + total_amount: 8398624, + group: 'low', + color: colors[4], + category1: 'One', + category2: 'Discretionary' }, + { bureau_title: 'Office of the Mayor', + id: 4, + total_amount: 592962, + group: 'medium', + color: colors[4], + category1: 'Two', + category2: 'Manditory' }, + { bureau_title: 'Portland Bureau of Transportation', + id: 5, + total_amount: 57546789, + group: 'high', + color: colors[6], + category1: 'Two', + category2: 'Manditory' }, + { bureau_title: 'Portland Housing Bureau', + id: 6, + total_amount: 11639339, + group: 'low', + color: colors[6], + category1: 'Two', + category2: 'Discretionary' }, + { bureau_title: 'Portland Parks & Recreation', + id: 7, + total_amount: 21829002, + group: 'medium', + color: colors[6], + category1: 'Three', + category2: 'Manditory' }, + { bureau_title: 'Portland Police Bureau', + id: 8, + total_amount: 2954388, + group: 'high', + color: colors[8], + category1: 'Three', + category2: 'Discretionary' }, + { bureau_title: 'Portland Water Bureau', + id: 9, + total_amount: 1585195, + group: 'low', + color: colors[8], + category1: 'Three', + category2: 'Manditory' }, +]; + +const demoCode = () => ( + +); + +const propDocs = { inline: true, propTables: [BubbleChart] }; + +export default () => storiesOf(displayName, module) + .addWithInfo( + title, + description, + demoCode, + propDocs, + ); diff --git a/stories/index.js b/stories/index.js index 9a25b50d..d85c4c9e 100644 --- a/stories/index.js +++ b/stories/index.js @@ -10,6 +10,7 @@ import sliderStory from './Slider.story'; import areaChartStory from './AreaChart.story'; import scrollToTopStory from './ScrollToTop.story'; import barChartStory from './BarChart.story'; +import bubbleChartStory from './BubbleChart.story'; import footerStory from './Footer.story'; import sankeyStory from './Sankey.story'; import headerStory from './Header.story'; @@ -42,6 +43,7 @@ pieStory(); areaChartStory(); dropdownStory(); barChartStory(); +bubbleChartStory(); footerStory(); sankeyStory(); scrollToTopStory();