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 }) =>
+ ;
+
+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();