├── .eslintrc.json ├── now.json ├── public ├── favicon.ico └── index.html ├── src ├── index.js ├── redux │ ├── selectors │ │ ├── theme.js │ │ ├── text.js │ │ ├── colors.js │ │ └── index.js │ ├── constants.js │ ├── reducers │ │ ├── tick.js │ │ ├── theme.js │ │ ├── colors.js │ │ ├── hover.js │ │ ├── text.js │ │ ├── renderCount.js │ │ └── index.js │ ├── configureStore.js │ └── actions.js ├── components │ ├── DemoBarChart.js │ ├── styled │ │ ├── Footer.js │ │ └── ChatInput.js │ ├── Tooltip.js │ ├── DemoScatterPlot.js │ ├── DemoPieChart.js │ ├── Pallet.js │ ├── Ticker.js │ ├── DemoChat.js │ ├── Dashboard.js │ └── charts │ │ ├── PieChart.js │ │ ├── ScatterPlot.js │ │ └── BarChart.js ├── utils │ ├── colors.js │ ├── themes.js │ └── stringStats.js ├── containers │ ├── Ticker.js │ ├── Dashboard.js │ ├── App.js │ ├── ThemedApp.js │ ├── DemoBarChart.js │ ├── DemoScatterPlot.js │ ├── ThemePicker.js │ ├── DemoChat.js │ └── DemoPieChart.js └── hocs │ ├── withMeasure.js │ ├── toJS.js │ └── withD3Renderer.js ├── .gitignore ├── README.md ├── LICENSE.md └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "standard-react"] 3 | } -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rd3", 4 | "alias": "rd3" 5 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tibotiber/rd3/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import App from 'containers/App' 5 | 6 | ReactDOM.render(, document.getElementById('root')) 7 | -------------------------------------------------------------------------------- /src/redux/selectors/theme.js: -------------------------------------------------------------------------------- 1 | import {createSelector} from 'reselect' 2 | import themes from 'utils/themes' 3 | 4 | const getThemeName = state => state 5 | 6 | export const getTheme = createSelector(getThemeName, theme => themes[theme]) 7 | -------------------------------------------------------------------------------- /src/redux/constants.js: -------------------------------------------------------------------------------- 1 | // action types 2 | export const NEW_TEXT = 'NEW_TEXT' 3 | export const SET_HOVER = 'SET_HOVER' 4 | export const TICK = 'TICK' 5 | export const SET_COLOR = 'SET_COLOR' 6 | export const INCREMENT_RENDER_COUNT = 'INCREMENT_RENDER_COUNT' 7 | export const SELECT_THEME = 'SELECT_THEME' 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | .chrome 19 | .vscode 20 | -------------------------------------------------------------------------------- /src/components/DemoBarChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import BarChart from 'components/charts/BarChart' 3 | 4 | const DemoBarChart = props => ( 5 |
6 | 7 |
8 | ) 9 | 10 | export default DemoBarChart 11 | -------------------------------------------------------------------------------- /src/components/styled/Footer.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Footer = styled.div` 4 | text-align: center; 5 | a { 6 | text-decoration: none; 7 | color: ${({theme}) => theme.secondaryColor} 8 | } 9 | a:hover { 10 | text-decoration: underline; 11 | } 12 | ` 13 | 14 | export default Footer 15 | -------------------------------------------------------------------------------- /src/redux/selectors/text.js: -------------------------------------------------------------------------------- 1 | import {createSelector} from 'reselect' 2 | 3 | export const getText = state => state 4 | 5 | export const getUsers = createSelector(getText, text => { 6 | return text.sortBy((v, k) => k).keySeq() 7 | }) 8 | 9 | export const getTexts = createSelector(getText, text => { 10 | return text.sortBy((v, k) => k).valueSeq() 11 | }) 12 | -------------------------------------------------------------------------------- /src/utils/colors.js: -------------------------------------------------------------------------------- 1 | import * as d3Chroma from 'd3-scale-chromatic' 2 | import _ from 'lodash' 3 | 4 | const DEFAULT_SATURATION = 0.55 5 | 6 | export const COLOR_PALLET = ['blue', 'green', 'grey', 'orange', 'purple', 'red'] 7 | 8 | export const getColorWithDefaultSaturation = colorName => { 9 | return d3Chroma[`interpolate${_.capitalize(colorName)}s`](DEFAULT_SATURATION) 10 | } 11 | -------------------------------------------------------------------------------- /src/redux/reducers/tick.js: -------------------------------------------------------------------------------- 1 | import {TICK} from 'redux/constants' 2 | 3 | const incrementTick = (state, action) => { 4 | return state + 1 5 | } 6 | 7 | const tickReducer = (state, action) => { 8 | switch (action.type) { 9 | case TICK: 10 | return incrementTick(state, action) 11 | default: 12 | return state 13 | } 14 | } 15 | 16 | export default tickReducer 17 | -------------------------------------------------------------------------------- /src/components/Tooltip.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const {string, object} = PropTypes 5 | 6 | const Tooltip = ({style = {}, content}) => ( 7 |
8 | {content} 9 |
10 | ) 11 | 12 | Tooltip.propTypes = { 13 | content: string, 14 | style: object 15 | } 16 | 17 | export default Tooltip 18 | -------------------------------------------------------------------------------- /src/redux/reducers/theme.js: -------------------------------------------------------------------------------- 1 | import {SELECT_THEME} from 'redux/constants' 2 | 3 | const selectTheme = (state, action) => { 4 | return action.theme 5 | } 6 | 7 | const themeReducer = (state, action) => { 8 | switch (action.type) { 9 | case SELECT_THEME: 10 | return selectTheme(state, action) 11 | default: 12 | return state 13 | } 14 | } 15 | export default themeReducer 16 | -------------------------------------------------------------------------------- /src/redux/configureStore.js: -------------------------------------------------------------------------------- 1 | import {createStore} from 'redux' 2 | import rootReducer, {initialState} from 'redux/reducers' 3 | 4 | const configureStore = () => { 5 | const store = createStore( 6 | rootReducer, 7 | initialState, 8 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() 9 | ) 10 | 11 | return store 12 | } 13 | 14 | export default configureStore 15 | -------------------------------------------------------------------------------- /src/redux/reducers/colors.js: -------------------------------------------------------------------------------- 1 | import {SET_COLOR} from 'redux/constants' 2 | 3 | const setColor = (state, action) => { 4 | return state.set(action.user, action.color) 5 | } 6 | 7 | const colorReducer = (state, action) => { 8 | switch (action.type) { 9 | case SET_COLOR: 10 | return setColor(state, action) 11 | default: 12 | return state 13 | } 14 | } 15 | 16 | export default colorReducer 17 | -------------------------------------------------------------------------------- /src/redux/reducers/hover.js: -------------------------------------------------------------------------------- 1 | import {fromJS} from 'immutable' 2 | import {SET_HOVER} from 'redux/constants' 3 | 4 | const setHover = (state, action) => { 5 | return fromJS(action.letters) 6 | } 7 | 8 | const hoverReducer = (state, action) => { 9 | switch (action.type) { 10 | case SET_HOVER: 11 | return setHover(state, action) 12 | default: 13 | return state 14 | } 15 | } 16 | 17 | export default hoverReducer 18 | -------------------------------------------------------------------------------- /src/redux/reducers/text.js: -------------------------------------------------------------------------------- 1 | import {fromJS} from 'immutable' 2 | import {NEW_TEXT} from 'redux/constants' 3 | 4 | const newText = (state, action) => { 5 | return state.mergeDeep(fromJS(action.text)) 6 | } 7 | 8 | const textReducer = (state, action) => { 9 | switch (action.type) { 10 | case NEW_TEXT: 11 | return newText(state, action) 12 | default: 13 | return state 14 | } 15 | } 16 | 17 | export default textReducer 18 | -------------------------------------------------------------------------------- /src/components/styled/ChatInput.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const ChatInput = styled.textarea` 4 | border: solid 1px ${({theme}) => theme.border}; 5 | border-left: 5px solid ${({color}) => color}; 6 | padding-left: 15px; 7 | margin: 10px; 8 | padding: 5px; 9 | width: ${({width}) => width}px; 10 | height: ${({height}) => height}px; 11 | background-color: ${({theme}) => theme.background}; 12 | color: ${({theme}) => theme.color}; 13 | ` 14 | 15 | export default ChatInput 16 | -------------------------------------------------------------------------------- /src/redux/selectors/colors.js: -------------------------------------------------------------------------------- 1 | import {createSelector} from 'reselect' 2 | import {getColorWithDefaultSaturation} from 'utils/colors' 3 | 4 | const getColors = state => state 5 | 6 | export const getSaturatedColors = createSelector(getColors, colors => { 7 | return colors.map(color => getColorWithDefaultSaturation(color)) 8 | }) 9 | 10 | export const getSaturatedColorsArray = createSelector( 11 | getSaturatedColors, 12 | colors => { 13 | return colors.sortBy((v, k) => k).valueSeq() 14 | } 15 | ) 16 | -------------------------------------------------------------------------------- /src/components/DemoScatterPlot.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ScatterPlot from 'components/charts/ScatterPlot' 3 | import {ALPHABET} from 'utils/stringStats' 4 | 5 | const DemoScatterPlot = props => ( 6 |
7 | 14 |
15 | ) 16 | 17 | export default DemoScatterPlot 18 | -------------------------------------------------------------------------------- /src/redux/reducers/renderCount.js: -------------------------------------------------------------------------------- 1 | import {INCREMENT_RENDER_COUNT} from 'redux/constants' 2 | 3 | const incrementRenderCount = (state, action) => { 4 | return state.updateIn( 5 | [action.component, action.mode], 6 | (value = 0) => value + 1 7 | ) 8 | } 9 | 10 | const renderCountReducer = (state, action) => { 11 | switch (action.type) { 12 | case INCREMENT_RENDER_COUNT: 13 | return incrementRenderCount(state, action) 14 | default: 15 | return state 16 | } 17 | } 18 | 19 | export default renderCountReducer 20 | -------------------------------------------------------------------------------- /src/containers/Ticker.js: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux' 2 | import toJS from 'hocs/toJS' 3 | import Ticker from 'components/Ticker' 4 | import {getTick, getRenderCount} from 'redux/selectors' 5 | import {tick} from 'redux/actions' 6 | 7 | const mapStateToProps = (state, ownProps) => ({ 8 | tickValue: getTick(state), 9 | renderCount: getRenderCount(state) 10 | }) 11 | 12 | const mapDispatchToProps = (dispatch, ownProps) => { 13 | return { 14 | tick: () => { 15 | dispatch(tick()) 16 | } 17 | } 18 | } 19 | 20 | export default connect(mapStateToProps, mapDispatchToProps)(toJS(Ticker)) 21 | -------------------------------------------------------------------------------- /src/containers/Dashboard.js: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux' 2 | import Dashboard from 'components/Dashboard' 3 | import {incrementRenderCount} from 'redux/actions' 4 | import toJS from 'hocs/toJS' 5 | import {getHover, getSaturatedColors} from 'redux/selectors' 6 | 7 | const mapStateToProps = (state, ownProps) => ({ 8 | hover: getHover(state), 9 | colors: getSaturatedColors(state) 10 | }) 11 | 12 | const mapDispatchToProps = (dispatch, ownProps) => ({ 13 | incrementRenderCount (mode) { 14 | dispatch(incrementRenderCount('dashboard', mode)) 15 | } 16 | }) 17 | 18 | export default connect(mapStateToProps, mapDispatchToProps)(toJS(Dashboard)) 19 | -------------------------------------------------------------------------------- /src/redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux-immutable' 2 | import {fromJS} from 'immutable' 3 | import text from './text' 4 | import colors from './colors' 5 | import hover from './hover' 6 | import tick from './tick' 7 | import renderCount from './renderCount' 8 | import theme from './theme' 9 | 10 | export const initialState = fromJS({ 11 | text: {}, 12 | colors: { 13 | user1: 'blue', 14 | user2: 'orange' 15 | }, 16 | hover: null, 17 | tick: 0, 18 | renderCount: {}, 19 | theme: 'dark' 20 | }) 21 | 22 | const rootReducer = combineReducers( 23 | {text, colors, hover, tick, renderCount, theme}, 24 | initialState 25 | ) 26 | 27 | export default rootReducer 28 | -------------------------------------------------------------------------------- /src/utils/themes.js: -------------------------------------------------------------------------------- 1 | import {darken, lighten} from 'polished' 2 | 3 | const themes = { 4 | default: { 5 | background: 'white', 6 | color: 'black', 7 | border: 'lightgrey', 8 | get secondaryBackground () { 9 | return darken(0.03, this.background) 10 | }, 11 | get secondaryColor () { 12 | return lighten(0.55, this.color) 13 | } 14 | }, 15 | dark: { 16 | background: '#222', 17 | color: 'lightgrey', 18 | border: '#666', 19 | get secondaryBackground () { 20 | return lighten(0.2, this.background) 21 | }, 22 | get secondaryColor () { 23 | return darken(0.2, this.color) 24 | } 25 | } 26 | } 27 | 28 | export default themes 29 | -------------------------------------------------------------------------------- /src/containers/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Provider} from 'react-redux' 3 | import configureStore from 'redux/configureStore' 4 | import ThemedApp from 'containers/ThemedApp' 5 | import Dashboard from 'containers/Dashboard' 6 | import Ticker from 'containers/Ticker' 7 | import Footer from 'components/styled/Footer' 8 | 9 | const store = configureStore() 10 | 11 | const App = () => ( 12 | 13 | 14 | 15 | 16 | 21 | 22 | 23 | ) 24 | 25 | export default App 26 | -------------------------------------------------------------------------------- /src/hocs/withMeasure.js: -------------------------------------------------------------------------------- 1 | /* NOTE: this is compatible with react-measure up to 1.4.7 only */ 2 | import React from 'react' 3 | import Measure from 'react-measure' 4 | 5 | const withMeasure = ( 6 | dimensionsToInject = ['width', 'height', 'top', 'right', 'bottom', 'left'], 7 | props = {} 8 | ) => WrappedComponent => wrappedComponentProps => ( 9 | 10 | {dimensions => { 11 | const injectedDimensions = {} 12 | dimensionsToInject.forEach(key => { 13 | injectedDimensions[key] = dimensions[key] 14 | }) 15 | return ( 16 | 17 | ) 18 | }} 19 | 20 | ) 21 | 22 | export default withMeasure 23 | -------------------------------------------------------------------------------- /src/hocs/toJS.js: -------------------------------------------------------------------------------- 1 | // see http://redux.js.org/docs/recipes/UsingImmutableJS.html#use-a-higher-order-component-to-convert-your-smart-components-immutablejs-props-to-your-dumb-components-javascript-props 2 | import React from 'react' 3 | import {Iterable} from 'immutable' 4 | 5 | const toJS = WrappedComponent => wrappedComponentProps => { 6 | const KEY = 0 7 | const VALUE = 1 8 | 9 | const propsJS = Object.entries( 10 | wrappedComponentProps 11 | ).reduce((newProps, wrappedComponentProp) => { 12 | newProps[wrappedComponentProp[KEY]] = Iterable.isIterable( 13 | wrappedComponentProp[VALUE] 14 | ) 15 | ? wrappedComponentProp[VALUE].toJS() 16 | : wrappedComponentProp[VALUE] 17 | return newProps 18 | }, {}) 19 | 20 | return 21 | } 22 | 23 | export default toJS 24 | -------------------------------------------------------------------------------- /src/redux/actions.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { 3 | NEW_TEXT, 4 | SET_HOVER, 5 | TICK, 6 | SET_COLOR, 7 | INCREMENT_RENDER_COUNT, 8 | SELECT_THEME 9 | } from 'redux/constants' 10 | 11 | export const newText = text => ({ 12 | type: NEW_TEXT, 13 | text 14 | }) 15 | 16 | export const setHover = letter => ({ 17 | type: SET_HOVER, 18 | letters: !letter ? null : Array.isArray(letter) ? _.uniq(letter) : [letter] 19 | }) 20 | 21 | export const tick = () => ({ 22 | type: TICK 23 | }) 24 | 25 | export const setColor = (user, color) => ({ 26 | type: SET_COLOR, 27 | user, 28 | color 29 | }) 30 | 31 | export const incrementRenderCount = (component, mode) => ({ 32 | type: INCREMENT_RENDER_COUNT, 33 | component, 34 | mode 35 | }) 36 | 37 | export const selectTheme = theme => ({ 38 | type: SELECT_THEME, 39 | theme 40 | }) 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RD3 - Playground for React & D3.js 2 | **[Live demo](https://rd3.now.sh)** 3 | 4 | *The demo instance may sleep when there is no traffic, if it takes time to load please hold on while the instance starts.* 5 | 6 | ## Blog post 7 | https://medium.com/@tibotiber/react-d3-js-balancing-performance-developer-experience-4da35f912484 8 | 9 | ## Description 10 | This is a pet project where I experiment with getting React and D3.js to play well together. The focus is on performance and developer experience, with for goal to enable a powerful data exploration experience for the user, while getting the best developer experience enabling fast prototyping and easy contributions to the dataviz code by D3.js developers with minimal background in React. 11 | 12 | Big shout out to [@Olical](https://github.com/Olical) for his great work on [react-faux-dom](https://github.com/Olical/react-faux-dom). 13 | -------------------------------------------------------------------------------- /src/redux/selectors/index.js: -------------------------------------------------------------------------------- 1 | import * as fromColors from './colors' 2 | import * as fromText from './text' 3 | import * as fromTheme from './theme' 4 | 5 | /** delegated to slice selectors **/ 6 | // colors 7 | export const getSaturatedColors = state => 8 | fromColors.getSaturatedColors(state.get('colors')) 9 | export const getSaturatedColorsArray = state => 10 | fromColors.getSaturatedColorsArray(state.get('colors')) 11 | // text 12 | export const getText = state => fromText.getText(state.get('text')) 13 | export const getUsers = state => fromText.getUsers(state.get('text')) 14 | export const getTexts = state => fromText.getTexts(state.get('text')) 15 | // theme 16 | export const getTheme = state => fromTheme.getTheme(state.get('theme')) 17 | 18 | /** top level selectors (simple cases) **/ 19 | export const getHover = state => state.get('hover') 20 | export const getRenderCount = state => state.get('renderCount') 21 | export const getTick = state => state.get('tick') 22 | -------------------------------------------------------------------------------- /src/components/DemoPieChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import PieChart from 'components/charts/PieChart' 4 | 5 | const {arrayOf, string, func, bool} = PropTypes 6 | 7 | const DemoPieChart = props => { 8 | const {hover, filter, toggleFilter, ...otherProps} = props 9 | const filteredData = hover 10 | ? ` (letter${hover.length > 1 ? 's' : ''}: ${hover.join(', ')})` 11 | : '' 12 | return ( 13 |
14 |
15 | 16 | Refresh with filtered data? 17 |
18 | 23 |
24 | ) 25 | } 26 | 27 | DemoPieChart.propTypes = { 28 | filter: bool, 29 | toggleFilter: func, 30 | hover: arrayOf(string) 31 | } 32 | 33 | export default DemoPieChart 34 | -------------------------------------------------------------------------------- /src/containers/ThemedApp.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import {connect} from 'react-redux' 4 | import styled, {ThemeProvider, injectGlobal} from 'styled-components' 5 | import ThemePicker from 'containers/ThemePicker' 6 | import {getTheme} from 'redux/selectors' 7 | 8 | const {object, array} = PropTypes 9 | 10 | /* eslint-disable no-unused-expressions */ 11 | injectGlobal` 12 | body { 13 | margin: 0; 14 | overflow: hidden; 15 | } 16 | ` 17 | 18 | const Root = styled.div` 19 | background-color: ${({theme}) => theme.background}; 20 | color: ${({theme}) => theme.color}; 21 | font: 11px sans-serif; 22 | padding: 8px; 23 | ` 24 | 25 | const ThemedApp = ({theme, children}) => ( 26 | 27 | 28 | {children} 29 | 30 | 31 | 32 | ) 33 | 34 | ThemedApp.propTypes = { 35 | theme: object, 36 | children: array 37 | } 38 | 39 | const mapStateToProps = (state, ownProps) => ({ 40 | theme: getTheme(state) 41 | }) 42 | 43 | export default connect(mapStateToProps)(ThemedApp) 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Thibaut Tiberghien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/containers/DemoBarChart.js: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux' 2 | import {createSelector} from 'reselect' 3 | import DemoBarChart from 'components/DemoBarChart' 4 | import {countLetters, ALPHABET} from 'utils/stringStats' 5 | import {setHover, incrementRenderCount} from 'redux/actions' 6 | import toJS from 'hocs/toJS' 7 | import {getText, getHover} from 'redux/selectors' 8 | 9 | const getData = createSelector(getText, text => { 10 | return ALPHABET.map(l => { 11 | return text.reduce( 12 | (result, userText, user) => { 13 | return { 14 | ...result, 15 | [user]: countLetters(userText, l) 16 | } 17 | }, 18 | {x: l} 19 | ) 20 | }) 21 | }) 22 | 23 | const mapStateToProps = (state, ownProps) => ({ 24 | data: getData(state), 25 | hover: getHover(state) 26 | }) 27 | 28 | const mapDispatchToProps = (dispatch, ownProps) => ({ 29 | setHover (letter) { 30 | dispatch(setHover(letter)) 31 | }, 32 | incrementRenderCount (mode) { 33 | dispatch(incrementRenderCount('barchart', mode)) 34 | } 35 | }) 36 | 37 | export default connect(mapStateToProps, mapDispatchToProps)(toJS(DemoBarChart)) 38 | -------------------------------------------------------------------------------- /src/containers/DemoScatterPlot.js: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux' 2 | import {createSelector} from 'reselect' 3 | import DemoScatterPlot from 'components/DemoScatterPlot' 4 | import {countLettersCoOccurrences} from 'utils/stringStats' 5 | import {setHover, incrementRenderCount} from 'redux/actions' 6 | import toJS from 'hocs/toJS' 7 | import {getText, getUsers} from 'redux/selectors' 8 | 9 | const getData = createSelector(getText, text => { 10 | return text.reduce((result, userText, user) => { 11 | return result.concat( 12 | countLettersCoOccurrences(userText).map(o => { 13 | return {group: user, x: o.letter1, y: o.letter2, n: o.count} 14 | }) 15 | ) 16 | }, []) 17 | }) 18 | 19 | const mapStateToProps = (state, ownProps) => ({ 20 | data: getData(state), 21 | groups: getUsers(state) 22 | }) 23 | 24 | const mapDispatchToProps = (dispatch, ownProps) => ({ 25 | setHover (letter) { 26 | dispatch(setHover(letter)) 27 | }, 28 | incrementRenderCount (mode) { 29 | dispatch(incrementRenderCount('scatterplot', mode)) 30 | } 31 | }) 32 | 33 | export default connect(mapStateToProps, mapDispatchToProps)( 34 | toJS(DemoScatterPlot) 35 | ) 36 | -------------------------------------------------------------------------------- /src/containers/ThemePicker.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import {connect} from 'react-redux' 4 | import styled from 'styled-components' 5 | import _ from 'lodash' 6 | import themes from 'utils/themes' 7 | import {selectTheme} from 'redux/actions' 8 | 9 | const {func} = PropTypes 10 | 11 | const List = styled.ul` 12 | position: absolute; 13 | bottom: 5px; 14 | right: 5px; 15 | margin: 0 8px 3px 0; 16 | ` 17 | 18 | const Item = styled.li` 19 | display: inline; 20 | margin-left: 5px; 21 | cursor: pointer; 22 | ` 23 | 24 | class ThemePicker extends React.Component { 25 | handleSelectTheme = theme => e => { 26 | this.props.selectTheme(theme) 27 | } 28 | 29 | render() { 30 | return ( 31 | 32 | {_.keys(themes).map(theme => ( 33 | 34 | {theme} 35 | 36 | ))} 37 | 38 | ) 39 | } 40 | } 41 | 42 | ThemePicker.propTypes = { 43 | selectTheme: func 44 | } 45 | 46 | const mapDispatchToProps = { 47 | selectTheme 48 | } 49 | 50 | export default connect(null, mapDispatchToProps)(ThemePicker) 51 | -------------------------------------------------------------------------------- /src/utils/stringStats.js: -------------------------------------------------------------------------------- 1 | export const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'.split('') 2 | 3 | export const countLettersOccurrences = str => { 4 | let occurrences = {} 5 | ALPHABET.forEach(l => { 6 | const letterRegex = new RegExp(l, 'gi') 7 | occurrences[l] = (str.match(letterRegex) || []).length 8 | }) 9 | return occurrences 10 | } 11 | 12 | export const countLetters = (str, letter) => { 13 | if (!letter) { 14 | return (str.match(/[a-zA-Z]/gi) || []).length 15 | } else if (Array.isArray(letter)) { 16 | const regex = new RegExp(`[${letter.join()}]`, 'gi') 17 | return (str.match(regex) || []).length 18 | } else { 19 | const regex = new RegExp(letter, 'gi') 20 | return (str.match(regex) || []).length 21 | } 22 | } 23 | 24 | export const countLettersCoOccurrences = str => { 25 | let occurrences = [] 26 | ALPHABET.forEach(l1 => { 27 | ALPHABET.forEach(l2 => { 28 | const regex = new RegExp(l1 + l2, 'gi') 29 | const count = (str.match(regex) || []).length 30 | if (count > 0) { 31 | occurrences.push({ 32 | letter1: l1, 33 | letter2: l2, 34 | count: count 35 | }) 36 | } 37 | }) 38 | }) 39 | return occurrences 40 | } 41 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | React D3 Playground 18 | 19 | 20 | 21 |
22 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/hocs/withD3Renderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import _ from 'lodash' 3 | import {shallowEqual} from 'recompose' 4 | 5 | const withD3Renderer = ({ 6 | resizeOn = ['width', 'height'], 7 | updateOn = ['data'] 8 | }) => WrappedComponent => { 9 | return class WithD3Renderer extends React.Component { 10 | setRef = wrappedComponentInstance => { 11 | this.component = wrappedComponentInstance 12 | } 13 | 14 | componentDidMount() { 15 | this.props.incrementRenderCount('component') 16 | this.props.incrementRenderCount('d3') 17 | this.component.renderD3('render') 18 | } 19 | 20 | componentDidUpdate(prevProps, prevState) { 21 | this.props.incrementRenderCount('component') 22 | const shouldResize = props => _.pick(props, resizeOn) 23 | if (!shallowEqual(shouldResize(this.props), shouldResize(prevProps))) { 24 | this.props.incrementRenderCount('d3') 25 | return this.component.renderD3('resize') 26 | } 27 | const shouldUpdate = props => _.pick(props, updateOn) 28 | if (!shallowEqual(shouldUpdate(this.props), shouldUpdate(prevProps))) { 29 | this.props.incrementRenderCount('d3') 30 | this.component.renderD3('update') 31 | } 32 | } 33 | 34 | render() { 35 | const {incrementRenderCount, ...otherProps} = this.props 36 | return 37 | } 38 | } 39 | } 40 | 41 | export default withD3Renderer 42 | -------------------------------------------------------------------------------- /src/components/Pallet.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from 'styled-components' 4 | 5 | const {arrayOf, shape, string, func} = PropTypes 6 | 7 | const List = styled.ul` 8 | list-style-type: none; 9 | text-align: right; 10 | vertical-align: center; 11 | margin-top: -8px; 12 | margin-right: 10px; 13 | ` 14 | 15 | const ColorSquare = styled.li` 16 | display: inline-block; 17 | width: 10px; 18 | height: 10px; 19 | margin-left: 5px; 20 | cursor: pointer; 21 | ` 22 | 23 | const PalletLabel = styled.li` 24 | display: inline-block; 25 | position: relative; 26 | top: -2px; 27 | margin-right: 5px; 28 | ` 29 | 30 | class Pallet extends React.Component { 31 | handleColorPicked = (scope, color) => e => { 32 | this.props.pickColor(scope, color) 33 | } 34 | 35 | render() { 36 | const {colors, scope} = this.props 37 | return ( 38 | 39 | {scope} 40 | {colors.map(color => ( 41 | 46 | ))} 47 | 48 | ) 49 | } 50 | } 51 | 52 | Pallet.propTypes = { 53 | colors: arrayOf( 54 | shape({ 55 | name: string, 56 | value: string 57 | }) 58 | ), 59 | scope: string, 60 | pickColor: func 61 | } 62 | 63 | export default Pallet 64 | -------------------------------------------------------------------------------- /src/containers/DemoChat.js: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux' 2 | import {createSelector} from 'reselect' 3 | import lorem from 'lorem-ipsum' 4 | import DemoChat from 'components/DemoChat' 5 | import {getColorWithDefaultSaturation, COLOR_PALLET} from 'utils/colors' 6 | import {newText, setColor, incrementRenderCount} from 'redux/actions' 7 | import toJS from 'hocs/toJS' 8 | import {getUsers, getTexts, getSaturatedColorsArray} from 'redux/selectors' 9 | 10 | const loremOption = { 11 | count: 2, 12 | units: 'sentences' 13 | } 14 | 15 | const getPallet = createSelector( 16 | () => COLOR_PALLET, 17 | pallet => { 18 | return pallet.map(color => { 19 | return { 20 | name: color, 21 | value: getColorWithDefaultSaturation(color) 22 | } 23 | }) 24 | } 25 | ) 26 | 27 | const mapStateToProps = (state, ownProps) => ({ 28 | users: getUsers(state), 29 | texts: getTexts(state), 30 | colors: getSaturatedColorsArray(state), 31 | pallet: getPallet(state) 32 | }) 33 | 34 | const mapDispatchToProps = (dispatch, ownProps) => ({ 35 | generateText () { 36 | dispatch( 37 | newText({ 38 | user1: lorem(loremOption), 39 | user2: lorem(loremOption) 40 | }) 41 | ) 42 | }, 43 | updateText (text) { 44 | dispatch(newText(text)) 45 | }, 46 | setUserColor (user, color) { 47 | dispatch(setColor(user, color)) 48 | }, 49 | incrementRenderCount (mode) { 50 | dispatch(incrementRenderCount('chat', mode)) 51 | } 52 | }) 53 | 54 | export default connect(mapStateToProps, mapDispatchToProps)(toJS(DemoChat)) 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rd3", 3 | "version": "0.4.2", 4 | "private": true, 5 | "license": "MIT", 6 | "author": "Thibaut Tiberghien ", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/tibotiber/rd3.git" 10 | }, 11 | "engines": { 12 | "node": ">=6.9.0" 13 | }, 14 | "dependencies": { 15 | "babel-polyfill": "^6.23.0", 16 | "d3": "^4.9.1", 17 | "d3-scale-chromatic": "^1.1.1", 18 | "immutable": "^3.8.1", 19 | "lodash": "^4.17.4", 20 | "lorem-ipsum": "^1.0.4", 21 | "polished": "^1.1.0", 22 | "prop-types": "^15.5.10", 23 | "react": "^15.5.4", 24 | "react-dom": "^15.4.2", 25 | "react-faux-dom": "^4.0.3", 26 | "react-grid-layout": "^0.14.6", 27 | "react-measure": "^1.4.7", 28 | "react-redux": "^5.0.3", 29 | "react-scripts": "0.9.5", 30 | "recompose": "^0.23.1", 31 | "redux": "^3.6.0", 32 | "redux-immutable": "^4.0.0", 33 | "reselect": "^3.0.0", 34 | "serve": "^5.1.4", 35 | "styled-components": "^2.0.0" 36 | }, 37 | "devDependencies": { 38 | "eslint": "^3.19.0", 39 | "eslint-config-standard": "^10.2.0", 40 | "eslint-config-standard-react": "^5.0.0", 41 | "eslint-plugin-node": "^4.2.2", 42 | "eslint-plugin-promise": "^3.5.0", 43 | "eslint-plugin-react": "^6.10.3", 44 | "eslint-plugin-standard": "^3.0.1", 45 | "prettier-eslint-cli": "^4.1.1" 46 | }, 47 | "scripts": { 48 | "start": "serve -s build", 49 | "dev": "NODE_PATH=./src react-scripts start", 50 | "build": "NODE_PATH=./src react-scripts build", 51 | "test": "react-scripts test --env=jsdom", 52 | "eject": "react-scripts eject", 53 | "lint-check": "prettier-eslint --list-different \"src/**/*.js\"", 54 | "lint": "prettier-eslint --write \"src/**/*.js\"" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/containers/DemoPieChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {connect} from 'react-redux' 3 | import {createSelector} from 'reselect' 4 | import DemoPieChart from 'components/DemoPieChart' 5 | import {countLetters} from 'utils/stringStats' 6 | import {incrementRenderCount} from 'redux/actions' 7 | import toJS from 'hocs/toJS' 8 | import {getText, getHover} from 'redux/selectors' 9 | 10 | const getFilterEnabled = (state, ownProps) => ownProps.filter 11 | 12 | const getAutoHover = createSelector( 13 | [getHover, getFilterEnabled], 14 | (hover, filter) => { 15 | return filter ? hover : null 16 | } 17 | ) 18 | 19 | const getData = createSelector([getText, getAutoHover], (text, hover) => { 20 | return text.reduce((result, userText, user) => { 21 | const nbOfLetters = countLetters(userText, hover ? hover.toJS() : null) 22 | result.push({ 23 | name: user, 24 | value: nbOfLetters 25 | }) 26 | return result 27 | }, []) 28 | }) 29 | 30 | const mapStateToProps = (state, ownProps) => ({ 31 | data: getData(state, ownProps), 32 | hover: getAutoHover(state, ownProps) 33 | }) 34 | 35 | const mapDispatchToProps = (dispatch, ownProps) => ({ 36 | incrementRenderCount(mode) { 37 | dispatch(incrementRenderCount('piechart', mode)) 38 | } 39 | }) 40 | 41 | const ConnectedPie = connect(mapStateToProps, mapDispatchToProps)( 42 | toJS(DemoPieChart) 43 | ) 44 | 45 | class AutoFilterPie extends React.Component { 46 | constructor(props) { 47 | super(props) 48 | this.state = { 49 | filterEnabled: true 50 | } 51 | } 52 | 53 | toggleFilter = () => { 54 | this.setState(state => ({ 55 | filterEnabled: !state.filterEnabled 56 | })) 57 | } 58 | 59 | render() { 60 | return ( 61 | 66 | ) 67 | } 68 | } 69 | 70 | export default AutoFilterPie 71 | -------------------------------------------------------------------------------- /src/components/Ticker.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from 'styled-components' 4 | import _ from 'lodash' 5 | import {transparentize} from 'polished' 6 | 7 | const {number, object, string, shape, func} = PropTypes 8 | 9 | const Toggle = styled.span` 10 | position: absolute; 11 | top: 0; 12 | right: 0; 13 | margin: 5px; 14 | cursor: pointer; 15 | ` 16 | 17 | const CodeBlock = Toggle.withComponent('pre').extend` 18 | padding: 15px; 19 | border: dashed 2px ${({theme}) => theme.secondaryColor}; 20 | color: ${({theme}) => theme.secondaryColor}; 21 | background-color: ${({theme}) => transparentize(0.2, theme.secondaryBackground)}; 22 | ` 23 | 24 | const RenderCount = ({component, counts}) => { 25 | return ( 26 |
27 | {component}: {counts.component}{counts.d3 ? ' / ' + counts.d3 : ''} 28 |
29 | ) 30 | } 31 | 32 | RenderCount.propTypes = { 33 | component: string, 34 | counts: shape({ 35 | component: number, 36 | d3: number 37 | }) 38 | } 39 | 40 | class Ticker extends React.PureComponent { 41 | static propTypes = { 42 | tickValue: number, 43 | renderCount: object, 44 | tick: func 45 | } 46 | 47 | state = { 48 | displayPanel: false 49 | } 50 | 51 | componentDidMount() { 52 | this.tickInterval = setInterval(this.props.tick, 1000) 53 | } 54 | 55 | componentWillUnmount() { 56 | clearInterval(this.tickInterval) 57 | } 58 | 59 | toggleDisplay = e => { 60 | this.setState(state => ({displayPanel: !state.displayPanel})) 61 | } 62 | 63 | render() { 64 | const {tickValue, renderCount} = this.props 65 | return this.state.displayPanel 66 | ? 67 |
tick: {tickValue}
68 | {_.values( 69 | _.mapValues(renderCount, (counts, component) => { 70 | return ( 71 | 76 | ) 77 | }) 78 | )} 79 |
80 | : Show Render Counts 81 | } 82 | } 83 | 84 | export default Ticker 85 | -------------------------------------------------------------------------------- /src/components/DemoChat.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from 'styled-components' 4 | import ChatInput from 'components/styled/ChatInput' 5 | import Pallet from 'components/Pallet' 6 | 7 | const {arrayOf, shape, string, func, number} = PropTypes 8 | 9 | const InlineDiv = styled.div` 10 | display: inline-block; 11 | ` 12 | 13 | class DemoChat extends React.Component { 14 | static propTypes = { 15 | users: arrayOf(string), 16 | texts: arrayOf(string), 17 | colors: arrayOf(string), 18 | generateText: func, 19 | updateText: func, 20 | pallet: arrayOf( 21 | shape({ 22 | name: string, 23 | value: string 24 | }) 25 | ), 26 | setUserColor: func, 27 | width: number, 28 | height: number, 29 | incrementRenderCount: func 30 | } 31 | 32 | state = { 33 | autoRefresh: false, 34 | refreshPeriod: 2 35 | } 36 | 37 | componentDidMount() { 38 | this.props.incrementRenderCount('component') 39 | this.props.generateText() 40 | } 41 | 42 | componentDidUpdate(prevProps, prevState) { 43 | this.props.incrementRenderCount('component') 44 | } 45 | 46 | handleChange = user => e => { 47 | this.props.updateText({[user]: e.target.value}) 48 | } 49 | 50 | toggleAutoRefresh = () => { 51 | this.setState(state => { 52 | const autoRefresh = !state.autoRefresh 53 | if (autoRefresh) { 54 | this.autoRefreshInterval = setInterval(() => { 55 | this.props.generateText() 56 | }, state.refreshPeriod * 1000) 57 | } else { 58 | clearInterval(this.autoRefreshInterval) 59 | } 60 | return {autoRefresh} 61 | }) 62 | } 63 | 64 | render() { 65 | const { 66 | users, 67 | texts, 68 | colors, 69 | width, 70 | height, 71 | pallet, 72 | setUserColor, 73 | generateText 74 | } = this.props 75 | const {autoRefresh, refreshPeriod} = this.state 76 | return ( 77 | 78 |
79 | {users.map((user, index) => { 80 | return ( 81 | 82 | 89 | 90 | 91 | ) 92 | })} 93 |
94 | 95 |
96 | 101 | Generate new text every {refreshPeriod}s 102 |
103 |
104 | ) 105 | } 106 | } 107 | 108 | export default DemoChat 109 | -------------------------------------------------------------------------------- /src/components/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from 'styled-components' 4 | import {transparentize} from 'polished' 5 | import _ from 'lodash' 6 | import ReactGridLayout, {WidthProvider} from 'react-grid-layout' 7 | import 'react-grid-layout/css/styles.css' 8 | import 'react-resizable/css/styles.css' 9 | import DemoBarChart from 'containers/DemoBarChart' 10 | import DemoPieChart from 'containers/DemoPieChart' 11 | import DemoScatterPlot from 'containers/DemoScatterPlot' 12 | import DemoChat from 'containers/DemoChat' 13 | import withMeasure from 'hocs/withMeasure' 14 | 15 | const {string, object, func, arrayOf} = PropTypes 16 | const GridLayout = WidthProvider(ReactGridLayout) 17 | const dimensions = ['width', 'height'] 18 | const MeasuredDemoBarChart = withMeasure(dimensions)(DemoBarChart) 19 | const MeasuredDemoScatterPlot = withMeasure(dimensions)(DemoScatterPlot) 20 | const MeasuredDemoPieChart = withMeasure(dimensions)(DemoPieChart) 21 | const MeasuredDemoChat = withMeasure(dimensions)(DemoChat) 22 | 23 | const generateDataGroupCSS = colors => { 24 | return _.reduce( 25 | colors, 26 | (result, color, user) => { 27 | result += `.data-group-${user} { fill: ${color}; }` 28 | return result 29 | }, 30 | '' 31 | ) 32 | } 33 | 34 | const generateHoverCss = letter => 35 | ` 36 | .data-${letter} { 37 | opacity: 1; 38 | -webkit-transition: opacity .2s ease-in; 39 | } 40 | ` 41 | 42 | const Grid = styled(GridLayout)` 43 | .axis text { 44 | fill: ${({theme}) => theme.color}; 45 | } 46 | .axis path, 47 | .axis line { 48 | fill: none; 49 | stroke: ${({theme}) => theme.color}; 50 | shape-rendering: crispEdges; 51 | } 52 | .stroked { 53 | stroke: ${({theme}) => theme.color}; 54 | } 55 | .stroked-negative { 56 | stroke: ${({theme}) => theme.background}; 57 | } 58 | ${({colors}) => generateDataGroupCSS(colors)} 59 | .data { 60 | opacity: ${({hover}) => (hover ? 0.25 : 1)}; 61 | -webkit-transition: opacity .2s ease-in; 62 | } 63 | ${({hover}) => hover && hover.map(letter => generateHoverCss(letter))} 64 | .tooltip { 65 | position: absolute; 66 | z-index: 10; 67 | display: inline-block; 68 | border: solid 1px ${({theme}) => theme.secondaryColor}; 69 | border-radius: 2px; 70 | padding: 5px; 71 | background-color: ${({theme}) => transparentize(0.2, theme.secondaryBackground)}; 72 | text-align: center; 73 | color: ${({theme}) => theme.secondaryColor}; 74 | } 75 | ` 76 | 77 | class Dashboard extends React.Component { 78 | static propTypes = { 79 | colors: object, 80 | hover: arrayOf(string), 81 | incrementRenderCount: func 82 | } 83 | 84 | componentDidMount() { 85 | this.props.incrementRenderCount('component') 86 | window.addEventListener('resize', this.onWindowResize) 87 | } 88 | 89 | componentDidUpdate(prevProps, prevState) { 90 | this.props.incrementRenderCount('component') 91 | } 92 | 93 | componentWillUnmount() { 94 | window.removeEventListener('resize', this.onWindowResize) 95 | } 96 | 97 | onWindowResize = e => { 98 | this.forceUpdate() 99 | } 100 | 101 | render() { 102 | const {hover, colors} = this.props 103 | const layout = [ 104 | {i: 'TL', x: 0, y: 0, w: 6, h: 7}, 105 | {i: 'TR', x: 6, y: 0, w: 6, h: 7}, 106 | {i: 'BL', x: 0, y: 7, w: 4, h: 5}, 107 | {i: 'BR', x: 4, y: 7, w: 8, h: 5} 108 | ] 109 | return ( 110 | 119 |
120 | 121 |
122 |
123 | 124 |
125 |
126 | 127 |
128 |
129 | 130 |
131 |
132 | ) 133 | } 134 | } 135 | 136 | export default Dashboard 137 | -------------------------------------------------------------------------------- /src/components/charts/PieChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import {withFauxDOM} from 'react-faux-dom' 4 | import styled from 'styled-components' 5 | import _ from 'lodash' 6 | import Tooltip from 'components/Tooltip' 7 | import withD3Renderer from 'hocs/withD3Renderer' 8 | const d3 = { 9 | ...require('d3-scale'), 10 | ...require('d3-selection'), 11 | ...require('d3-transition'), 12 | ...require('d3-shape'), 13 | ...require('d3-interpolate') 14 | } 15 | 16 | const {arrayOf, string, number, shape} = PropTypes 17 | const LOADING = 'loading...' 18 | 19 | const Title = styled.div` 20 | text-align: center; 21 | position: relative; 22 | top: -${({height}) => height * 1 / 5}px; 23 | ` 24 | 25 | const Wrapper = styled.div` 26 | position: relative; 27 | display: inline-block; 28 | .tooltip { 29 | width: ${({width}) => width / 5}px; 30 | left: ${({width}) => width * 2 / 5}px; 31 | top: ${({height}) => height * 3 / 5}px; 32 | } 33 | ` 34 | 35 | class PieChart extends React.Component { 36 | static propTypes = { 37 | data: arrayOf( 38 | shape({ 39 | name: string, 40 | value: number 41 | }) 42 | ), 43 | width: number, 44 | height: number, 45 | thickness: number, 46 | title: string 47 | } 48 | 49 | state = { 50 | tooltip: null 51 | } 52 | 53 | setTooltip = user => { 54 | this.setState(state => ({tooltip: user})) 55 | } 56 | 57 | computeTooltipContent = () => { 58 | const hoveredData = _.find(this.props.data, { 59 | name: this.state.tooltip 60 | }).value 61 | return `${this.state.tooltip}: ${hoveredData}` 62 | } 63 | 64 | render() { 65 | const {width, height, title, chart} = this.props 66 | return ( 67 | 68 | {chart} 69 | {title} 70 | {this.state.tooltip && 71 | } 72 | 73 | ) 74 | } 75 | 76 | renderD3 = mode => { 77 | const { 78 | width, 79 | height, 80 | thickness, 81 | connectFauxDOM, 82 | animateFauxDOM 83 | } = this.props 84 | 85 | // rendering mode 86 | const render = mode === 'render' 87 | const resize = mode === 'resize' 88 | 89 | // d3 helpers 90 | const outerRadius = Math.min(width, height) / 2 - 10 91 | const innerRadius = outerRadius - thickness 92 | let data = _.cloneDeep(this.props.data) // pie() mutates data 93 | var arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius) 94 | var pie = d3 95 | .pie() 96 | .value(d => d.value) 97 | .sort(null) 98 | .startAngle(-120 * Math.PI / 180) 99 | .endAngle(120 * Math.PI / 180) 100 | .padAngle(0.01) 101 | 102 | // arc transitions, see https://bl.ocks.org/mbostock/1346410 103 | // do not use arrow function here as scope is the path element 104 | function arcTween(a) { 105 | const i = d3.interpolate(this._current, a) 106 | this._current = i(0) 107 | return t => arc(i(t)) 108 | } 109 | 110 | // create a faux div and store its virtual DOM in state.chart 111 | let faux = connectFauxDOM('div', 'chart') 112 | 113 | let svg 114 | if (render) { 115 | svg = d3 116 | .select(faux) 117 | .append('svg') 118 | .attr('width', width) 119 | .attr('height', height) 120 | .append('g') 121 | .attr('transform', `translate(${width / 2}, ${height / 2})`) 122 | } else if (resize) { 123 | svg = d3 124 | .select(faux) 125 | .select('svg') 126 | .attr('width', width) 127 | .attr('height', height) 128 | .select('g') 129 | .attr('transform', `translate(${width / 2}, ${height / 2})`) 130 | } else { 131 | svg = d3.select(faux).select('svg').select('g') 132 | } 133 | 134 | let arcs = svg.selectAll('path').data(pie(data)) 135 | arcs 136 | .enter() 137 | .append('path') 138 | .attr('class', (d, i) => `data-group data-group-${data[i].name}`) 139 | .attr('d', arc) 140 | .each(function(d) { 141 | // store the initial angles for transitions 142 | // do not use arrow function here as scope is the path element 143 | this._current = d 144 | }) 145 | .on('mouseover', (d, i) => { 146 | clearTimeout(this.unsetTooltipTimeout) 147 | this.setTooltip(data[i].name) 148 | }) 149 | .on('mouseout', (d, i) => { 150 | this.unsetTooltipTimeout = setTimeout(() => this.setTooltip(null), 200) 151 | }) 152 | arcs.transition().attrTween('d', arcTween) 153 | animateFauxDOM(800) 154 | } 155 | } 156 | 157 | PieChart.defaultProps = { 158 | chart: LOADING 159 | } 160 | 161 | export default withFauxDOM( 162 | withD3Renderer({updateOn: ['data', 'thickness', 'title']})(PieChart) 163 | ) 164 | -------------------------------------------------------------------------------- /src/components/charts/ScatterPlot.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import {withFauxDOM} from 'react-faux-dom' 4 | import styled from 'styled-components' 5 | import _ from 'lodash' 6 | import Tooltip from 'components/Tooltip' 7 | import withD3Renderer from 'hocs/withD3Renderer' 8 | const d3 = { 9 | ...require('d3-scale'), 10 | ...require('d3-axis'), 11 | ...require('d3-selection'), 12 | ...require('d3-transition') 13 | } 14 | 15 | const {arrayOf, string, number, shape, func, array} = PropTypes 16 | const LOADING = 'loading...' 17 | 18 | const Wrapper = styled.div` 19 | position: relative; 20 | display: inline-block; 21 | ` 22 | 23 | class ScatterPlot extends React.Component { 24 | static propTypes = { 25 | data: arrayOf( 26 | shape({ 27 | group: string, 28 | x: string, 29 | y: string, 30 | n: number 31 | }) 32 | ), 33 | width: number, 34 | height: number, 35 | xDomain: array, 36 | yDomain: array, 37 | title: string, 38 | groups: arrayOf(string), 39 | radiusFactor: number, 40 | setHover: func 41 | } 42 | 43 | state = { 44 | tooltip: null 45 | } 46 | 47 | setTooltip = (group, x, y) => { 48 | this.setState(state => ({ 49 | tooltip: group ? {group, x, y} : null 50 | })) 51 | } 52 | 53 | computeTooltipProps = () => { 54 | const {group, x, y} = this.state.tooltip 55 | const hoveredData = _.find(this.props.data, {group, x, y}) 56 | if (hoveredData) { 57 | return { 58 | content: `"${x}${y}": ${hoveredData.n} in ${hoveredData.group}`, 59 | style: {top: this.y(y) - 18, left: this.x(x) - 8} 60 | } 61 | } else { 62 | return { 63 | style: {visibility: 'hidden'} 64 | } 65 | } 66 | } 67 | 68 | render() { 69 | return ( 70 | 71 | {this.props.chart} 72 | {this.state.tooltip && } 73 | 74 | ) 75 | } 76 | 77 | renderD3 = mode => { 78 | const { 79 | width, 80 | height, 81 | xDomain, 82 | yDomain, 83 | groups, 84 | setHover, 85 | radiusFactor, 86 | title, 87 | connectFauxDOM, 88 | animateFauxDOM 89 | } = this.props 90 | 91 | // rendering mode 92 | const render = mode === 'render' 93 | const resize = mode === 'resize' 94 | 95 | // d3 helpers 96 | const data = _.orderBy(_.cloneDeep(this.props.data), 'n', 'desc') // d3 mutates data 97 | const margin = {top: 20, right: 20, bottom: 50, left: 30} 98 | const graphWidth = width - margin.left - margin.right 99 | const graphHeight = height - margin.top - margin.bottom 100 | const x = d3.scalePoint().domain(xDomain).rangeRound([0, graphWidth]) 101 | this.x = x 102 | const dx = d3.scalePoint().domain(groups).rangeRound([-2, 2]) 103 | const y = d3.scalePoint().domain(yDomain).rangeRound([graphHeight, 0]) 104 | this.y = y 105 | const xAxis = d3.axisBottom().scale(x) 106 | const yAxis = d3.axisLeft().scale(y) 107 | 108 | // create a faux div and store its virtual DOM in state.chart 109 | let faux = connectFauxDOM('div', 'chart') 110 | 111 | let svg 112 | if (render) { 113 | svg = d3 114 | .select(faux) 115 | .append('svg') 116 | .attr('width', width) 117 | .attr('height', height) 118 | .append('g') 119 | .attr('transform', `translate(${margin.left}, ${margin.top})`) 120 | } else if (resize) { 121 | svg = d3 122 | .select(faux) 123 | .select('svg') 124 | .attr('width', width) 125 | .attr('height', height) 126 | .select('g') 127 | .attr('transform', `translate(${margin.left}, ${margin.top})`) 128 | } else { 129 | svg = d3.select(faux).select('svg').select('g') 130 | } 131 | 132 | let dots = svg.selectAll('.dot').data(data, d => d.group + d.x + d.y) 133 | dots = dots 134 | .enter() 135 | .append('circle') 136 | .attr( 137 | 'class', 138 | d => 139 | `dot stroked-negative data-group data-group-${d.group} data data-${d.x} data-${d.y}` 140 | ) 141 | .attr('r', 0) 142 | .attr('cx', d => x(d.x) + dx(d.group)) 143 | .attr('cy', d => y(d.y)) 144 | .on('mouseover', d => { 145 | clearTimeout(this.unsetHoverTimeout) 146 | setHover([d.x, d.y]) 147 | this.setTooltip(d.group, d.x, d.y) 148 | }) 149 | .on('mouseout', d => { 150 | this.unsetHoverTimeout = setTimeout(() => { 151 | setHover(null) 152 | this.setTooltip(null) 153 | }, 200) 154 | }) 155 | .merge(dots) 156 | 157 | dots 158 | .transition() 159 | .attr('r', d => d.n * radiusFactor) 160 | .attr('cx', d => x(d.x) + dx(d.group)) 161 | .attr('cy', d => y(d.y)) 162 | 163 | dots.exit().transition().attr('r', 0).remove() 164 | 165 | animateFauxDOM(800) 166 | 167 | if (render) { 168 | svg 169 | .append('g') 170 | .attr('class', 'x axis') 171 | .attr('transform', `translate(0, ${graphHeight})`) 172 | .call(xAxis) 173 | .append('text') 174 | .attr('class', 'label') 175 | .attr('x', graphWidth / 2) 176 | .attr('y', 35) 177 | .style('text-anchor', 'middle') 178 | .text(title) 179 | 180 | svg.append('g').attr('class', 'y axis').call(yAxis) 181 | } else if (resize) { 182 | svg 183 | .select('g.x.axis') 184 | .attr('transform', `translate(0, ${graphHeight})`) 185 | .call(xAxis) 186 | .select('text') 187 | .attr('x', graphWidth / 2) 188 | svg.select('g.y.axis').call(yAxis) 189 | } else { 190 | svg.select('g.x.axis').call(xAxis) 191 | svg.select('g.y.axis').call(yAxis) 192 | } 193 | } 194 | } 195 | 196 | ScatterPlot.defaultProps = { 197 | chart: LOADING 198 | } 199 | 200 | export default withFauxDOM( 201 | withD3Renderer({ 202 | updateOn: ['data', 'xDomain', 'yDomain', 'title', 'radiusFactor'] 203 | })(ScatterPlot) 204 | ) 205 | -------------------------------------------------------------------------------- /src/components/charts/BarChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import {withFauxDOM} from 'react-faux-dom' 4 | import styled from 'styled-components' 5 | import _ from 'lodash' 6 | import Tooltip from 'components/Tooltip' 7 | import withD3Renderer from 'hocs/withD3Renderer' 8 | const d3 = { 9 | ...require('d3-shape'), 10 | ...require('d3-array'), 11 | ...require('d3-scale'), 12 | ...require('d3-axis'), 13 | ...require('d3-selection'), 14 | ...require('d3-transition') 15 | } 16 | 17 | const {arrayOf, string, number, func, object} = PropTypes 18 | const LOADING = 'loading...' 19 | 20 | const Wrapper = styled.div` 21 | position: relative; 22 | display: inline-block; 23 | .tooltip { 24 | visibility: ${({hover}) => (hover ? 'visible' : 'hidden')}; 25 | -webkit-transition: top .2s ease-out, left .2s ease-out; 26 | } 27 | ` 28 | 29 | class BarChart extends React.Component { 30 | static propTypes = { 31 | data: arrayOf(object), 32 | xLabel: string, 33 | yLabel: string, 34 | width: number, 35 | height: number, 36 | hover: arrayOf(string), 37 | setHover: func 38 | } 39 | 40 | state = { 41 | look: 'stacked' 42 | } 43 | 44 | computeTooltipProps = letter => { 45 | const hoveredData = _.omit(_.find(this.props.data, {x: letter}), 'x') 46 | const computeTop = this.state.look === 'stacked' 47 | ? arr => this.y(_.sum(arr)) 48 | : arr => this.y(_.max(arr)) 49 | return { 50 | style: { 51 | top: computeTop(_.values(hoveredData)) + 5, 52 | left: this.x(letter) + 40 53 | }, 54 | content: `${letter}: ${_.values(hoveredData).join(', ')}` 55 | } 56 | } 57 | 58 | render() { 59 | const {hover, chart} = this.props 60 | return ( 61 | 62 | 63 | {chart} 64 | {chart !== LOADING && 65 | hover && 66 | hover.map((letter, index) => ( 67 | 68 | ))} 69 | 70 | ) 71 | } 72 | 73 | toggle = () => { 74 | if (this.state.look === 'stacked') { 75 | this.setState(state => ({look: 'grouped'})) 76 | this.transitionGrouped() 77 | } else { 78 | this.setState(state => ({look: 'stacked'})) 79 | this.transitionStacked() 80 | } 81 | } 82 | 83 | renderD3 = mode => { 84 | const { 85 | width, 86 | height, 87 | xLabel, 88 | yLabel, 89 | setHover, 90 | connectFauxDOM, 91 | animateFauxDOM 92 | } = this.props 93 | 94 | // rendering mode 95 | const render = mode === 'render' 96 | const resize = mode === 'resize' 97 | 98 | // d3 helpers 99 | let data = _.cloneDeep(this.props.data) // stack() mutates data 100 | const groups = _.without(_.keys(data[0]), 'x') 101 | const n = groups.length // number of layers 102 | const layers = d3.stack().keys(groups)(data) 103 | const yStackMax = d3.max(layers, layer => d3.max(layer, d => d[1])) 104 | const margin = {top: 20, right: 10, bottom: 50, left: 50} 105 | const graphWidth = width - margin.left - margin.right 106 | const graphHeight = height - margin.top - margin.bottom - 18 107 | const x = d3 108 | .scaleBand() 109 | .domain(data.map(d => d.x)) 110 | .rangeRound([0, graphWidth]) 111 | .paddingInner(0.08) 112 | this.x = x 113 | const y = d3.scaleLinear().domain([0, yStackMax]).range([graphHeight, 0]) 114 | this.y = y 115 | const xAxis = d3.axisBottom().scale(x) 116 | const yAxis = d3.axisLeft().scale(y) 117 | 118 | // create a faux div and store its virtual DOM in state.chart 119 | let faux = connectFauxDOM('div', 'chart') 120 | 121 | let svg 122 | if (render) { 123 | svg = d3 124 | .select(faux) 125 | .append('svg') 126 | .attr('width', width) 127 | .attr('height', height) 128 | .append('g') 129 | .attr('transform', `translate(${margin.left}, ${margin.top})`) 130 | } else if (resize) { 131 | svg = d3 132 | .select(faux) 133 | .select('svg') 134 | .attr('width', width) 135 | .attr('height', height) 136 | .select('g') 137 | .attr('transform', `translate(${margin.left}, ${margin.top})`) 138 | } else { 139 | svg = d3.select(faux).select('svg').select('g') 140 | } 141 | 142 | let layer = svg.selectAll('.layer').data(layers) 143 | layer = layer 144 | .enter() 145 | .append('g') 146 | .attr('class', d => `layer data-group data-group-${d.key}`) 147 | .merge(layer) 148 | 149 | let rect = layer.selectAll('rect').data(d => d) 150 | rect = rect 151 | .enter() 152 | .append('rect') 153 | .attr('class', d => `data data-${d.data.x}`) 154 | .attr('x', d => x(d.data.x)) 155 | .attr('y', graphHeight) 156 | .attr('width', x.bandwidth()) 157 | .attr('height', 0) 158 | .on('mouseover', d => { 159 | clearTimeout(this.unsetHoverTimeout) 160 | setHover(d.data.x) 161 | }) 162 | .on('mouseout', d => { 163 | this.unsetHoverTimeout = setTimeout(() => setHover(null), 200) 164 | }) 165 | .merge(rect) 166 | 167 | if (this.state.look === 'stacked') { 168 | rect 169 | .transition() 170 | .delay((d, i) => (resize ? 0 : i * 10)) 171 | .attr('x', d => x(d.data.x)) 172 | .attr('y', d => y(d[1])) 173 | .attr('width', x.bandwidth()) 174 | .attr('height', d => y(d[0]) - y(d[1])) 175 | } else { 176 | rect 177 | .transition() 178 | .delay((d, i) => (resize ? 0 : i * 10)) 179 | .attr('x', function(d, i) { 180 | let layerIndex = this.parentNode.__data__.index 181 | return x(d.data.x) + x.bandwidth() / n * layerIndex 182 | }) 183 | .attr('y', d => y(d[1] - d[0])) 184 | .attr('width', x.bandwidth() / n) 185 | .attr('height', d => graphHeight - y(d[1] - d[0])) 186 | } 187 | animateFauxDOM(800) 188 | 189 | if (render) { 190 | svg 191 | .append('g') 192 | .attr('class', 'x axis') 193 | .attr('transform', `translate(0, ${graphHeight})`) 194 | .call(xAxis) 195 | .append('text') 196 | .attr('class', 'label') 197 | .attr('x', graphWidth) 198 | .attr('y', 35) 199 | .style('text-anchor', 'end') 200 | .text(xLabel) 201 | 202 | svg 203 | .append('g') 204 | .attr('class', 'y axis') 205 | .attr('transform', 'translate(0, 0)') 206 | .call(yAxis) 207 | .append('text') 208 | .attr('class', 'label') 209 | .attr('transform', 'rotate(-90)') 210 | .attr('y', -30) 211 | .style('text-anchor', 'end') 212 | .text(yLabel) 213 | } else if (resize) { 214 | svg 215 | .select('g.x.axis') 216 | .attr('transform', `translate(0, ${graphHeight})`) 217 | .call(xAxis) 218 | .select('text') 219 | .attr('x', graphWidth) 220 | svg.select('g.y.axis').call(yAxis) 221 | } else { 222 | svg.select('g.x.axis').call(xAxis) 223 | svg.select('g.y.axis').call(yAxis) 224 | } 225 | 226 | this.transitionGrouped = () => { 227 | rect 228 | .transition() 229 | .duration(500) 230 | // .delay((d, i) => i * 10) 231 | .attr('x', function(d, i) { 232 | let layerIndex = this.parentNode.__data__.index 233 | return x(d.data.x) + x.bandwidth() / n * layerIndex 234 | }) 235 | .attr('width', x.bandwidth() / n) 236 | .transition() 237 | .attr('y', d => y(d[1] - d[0])) 238 | .attr('height', d => graphHeight - y(d[1] - d[0])) 239 | animateFauxDOM(2000) 240 | } 241 | 242 | this.transitionStacked = () => { 243 | rect 244 | .transition() 245 | .duration(500) 246 | // .delay((d, i) => i * 10) 247 | .attr('y', d => y(d[1])) 248 | .attr('height', d => y(d[0]) - y(d[1])) 249 | .transition() 250 | .attr('x', d => x(d.data.x)) 251 | .attr('width', x.bandwidth()) 252 | animateFauxDOM(2000) 253 | } 254 | } 255 | } 256 | 257 | BarChart.defaultProps = { 258 | chart: LOADING 259 | } 260 | 261 | export default withFauxDOM( 262 | withD3Renderer({updateOn: ['data', 'xLabel', 'yLabel']})(BarChart) 263 | ) 264 | --------------------------------------------------------------------------------