├── .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 |
--------------------------------------------------------------------------------