├── src ├── App │ ├── App.css │ ├── LineChart │ │ ├── Brush │ │ │ ├── index.js │ │ │ └── Brush.js │ │ ├── LineChart.css │ │ ├── index.js │ │ ├── ResponsiveLineChart.js │ │ ├── constants.js │ │ ├── SvgWrapper.js │ │ ├── LineChart.js │ │ └── LineWithBrush.js │ ├── index.js │ ├── App.test.js │ └── App.js ├── index.js ├── index.css └── registerServiceWorker.js ├── images └── example.gif ├── public ├── favicon.ico ├── manifest.json └── index.html ├── .gitignore ├── scripts └── csvtojson.js ├── package.json ├── LICENSE └── README.md /src/App/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | width: 100vw; 3 | height: 100vh; 4 | } 5 | -------------------------------------------------------------------------------- /images/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guzmonne/nivo-with-brush/HEAD/images/example.gif -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guzmonne/nivo-with-brush/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/App/LineChart/Brush/index.js: -------------------------------------------------------------------------------- 1 | import Brush from './Brush.js'; 2 | 3 | export default Brush; 4 | -------------------------------------------------------------------------------- /src/App/index.js: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import App from './App.js'; 3 | 4 | export default App; 5 | -------------------------------------------------------------------------------- /src/App/LineChart/LineChart.css: -------------------------------------------------------------------------------- 1 | .LineChart { 2 | height: 100%; 3 | width: 100%; 4 | } 5 | 6 | .LineChart > .Main { 7 | height: 80%; 8 | } 9 | 10 | .LineChart > .Brush { 11 | height: 20%; 12 | } 13 | -------------------------------------------------------------------------------- /src/App/LineChart/index.js: -------------------------------------------------------------------------------- 1 | import './LineChart.css'; 2 | 3 | import LineChart from './LineChart.js'; 4 | import ResponsiveLineChart from './ResponsiveLineChart.js'; 5 | 6 | export { LineChart, ResponsiveLineChart }; 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import App from './App/'; 5 | import registerServiceWorker from './registerServiceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | registerServiceWorker(); 9 | -------------------------------------------------------------------------------- /src/App/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './index.js'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body { 7 | height: 100%; 8 | padding: 0; 9 | margin: 0; 10 | } 11 | 12 | body { 13 | /* System Fonts as used by GitHub */ 14 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, 15 | Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 16 | } 17 | -------------------------------------------------------------------------------- /.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.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App/LineChart/ResponsiveLineChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ResponsiveWrapper } from '@nivo/core'; 3 | import LineChart from './LineChart.js'; 4 | 5 | var ResponsiveLineChart = props => ( 6 | 7 | {({ width, height }) => ( 8 | 9 | )} 10 | 11 | ); 12 | 13 | export default ResponsiveLineChart; 14 | -------------------------------------------------------------------------------- /src/App/LineChart/constants.js: -------------------------------------------------------------------------------- 1 | // Maxima cantidad de elementos por gráfica 2 | export var MAX_ITEMS = 100; 3 | // Valor de offset de prueba 4 | export var INDEX_OFFSET = 8350; 5 | // Ancho de un tick con la fecha 6 | export var TICK_WIDTH = 30; 7 | // Tamaño de padding del brush. 8 | export var PADDING = 5; 9 | // Tiempo de debounce para filtrar la canidad de elementos en la gráfica. 10 | export var DEBOUNCE = 100; 11 | // Cantidad de puntos por pixel permitidos en el ancho 12 | export var POINTS_PER_WIDTH = 10; 13 | -------------------------------------------------------------------------------- /scripts/csvtojson.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var csv = require('csvtojson'); 4 | var fs = require('fs'); 5 | var path = require('path'); 6 | 7 | var csvFilePath = path.resolve(__dirname, 'AAPL.US.csv'); 8 | 9 | var result = { 10 | id: 'AAPL', 11 | color: 'hsl(36, 100%, 50%)', 12 | data: [] 13 | }; 14 | 15 | var x, y; 16 | 17 | csv() 18 | .fromFile(csvFilePath) 19 | .on('json', json => { 20 | x = json.Date; 21 | y = parseFloat(json.Close, 10); 22 | result.data = [{ x, y }].concat(result.data); 23 | }) 24 | .on('done', error => { 25 | result.data = result.data.slice(0, -1); 26 | var output = path.resolve(__dirname, '../src/App/stock.json'); 27 | fs.writeFileSync(output, JSON.stringify([result], null, 2), 'utf8'); 28 | }); 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nivo-brush", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "csvtojson": "^1.1.9", 7 | "react-scripts": "1.1.0" 8 | }, 9 | "dependencies": { 10 | "@nivo/core": "^0.33.0", 11 | "@nivo/legends": "^0.33.0", 12 | "@nivo/line": "^0.33.0", 13 | "@tweenjs/tween.js": "^17.1.1", 14 | "d3": "^4.12.2", 15 | "lodash": "^4.17.4", 16 | "lodash-es": "^4.17.4", 17 | "prop-types": "^15.6.0", 18 | "react": "^16.2.0", 19 | "react-dom": "^16.2.0", 20 | "recompose": "^0.26.0" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test --env=jsdom", 26 | "eject": "react-scripts eject" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Guzman Monne 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 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/App/LineChart/SvgWrapper.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of the nivo project. 3 | * 4 | * Copyright 2016-present, Raphaël Benitte. 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | import React from 'react'; 10 | import PropTypes from 'prop-types'; 11 | import { Defs } from '@nivo/core/lib/components/defs/'; 12 | import compose from 'recompose/compose'; 13 | import pure from 'recompose/pure'; 14 | import withHandlers from 'recompose/withHandlers.js'; 15 | import toClass from 'recompose/toClass.js'; 16 | 17 | const SvgWrapper = ({ width, height, margin, defs, children, getSvgRef }) => { 18 | var childrenWithProps = React.Children.map(children, child => { 19 | if (child === null) return child; 20 | 21 | return React.cloneElement(child, { getSvgRef }); 22 | }); 23 | return ( 24 | (this.svg = svgRef)} 29 | > 30 | 31 | 32 | {childrenWithProps} 33 | 34 | 35 | ); 36 | }; 37 | 38 | SvgWrapper.propTypes = { 39 | width: PropTypes.number.isRequired, 40 | height: PropTypes.number.isRequired, 41 | margin: PropTypes.shape({ 42 | top: PropTypes.number.isRequired, 43 | left: PropTypes.number.isRequired 44 | }).isRequired, 45 | defs: PropTypes.array 46 | }; 47 | 48 | var enhance = compose( 49 | toClass, 50 | withHandlers({ 51 | getSvgRef: () => () => this.svg 52 | }), 53 | pure 54 | ); 55 | 56 | var EnhancedSvgWrapper = enhance(SvgWrapper); 57 | EnhancedSvgWrapper.displayName = 'enhanced(SvgWrapper)'; 58 | 59 | export default EnhancedSvgWrapper; 60 | -------------------------------------------------------------------------------- /src/App/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { ResponsiveLineChart as LineChart } from './LineChart/'; 3 | import stocks from './stock.json'; 4 | 5 | stocks.forEach(stock => (stock.data = stock.data.reverse())); 6 | 7 | class App extends Component { 8 | render() { 9 | return ( 10 |
11 | 76 |
77 | ); 78 | } 79 | } 80 | 81 | export default App; 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nivo Line Chart with Brush example. 2 | 3 | [Nivo](http://nivo.rocks/) is a great library to build beautiful charts with ease, but lacks some functionalities. For one of my projects, I wanted to add a brush under the chart, to allow the user to select a specific portion of the chart to view. I have tried on other occassions to build this kind of component from scratch, and I have not been very successful, so instead, I tried to incorporate it into Nivo. 4 | 5 | ## Explanation 6 | 7 | After studying a littel bit how the components are designed in Nivo, I realized that there was not a way to draw a "brush" over the chart. The line component doesn't accept children components. 8 | 9 | To solve this, I created a new component, using the current `Line` component as base, and added a `Brush` component as one of its children. Then I modified the `props` that the component consumes, to pass it down to the `Brush` components. These are: 10 | 11 | * `onBrush`: a callback function that returns the `min` and `max` edges coordinates of the brush. 12 | * `initialMinEdge`: initial min edge of the brush. 13 | * `initialMaxEdge`: initial max edge of the brush. 14 | 15 | The `Brush` component I designed is very simple. It only works on one axis, and it represents the selected area with a gray square. You can grow or shrink the selection, drag it, or create a new one. Every time the edges of the `Brush` are updated, it will call the `onBrush` callback, with those values. 16 | 17 | On my example, I manipulated the data sent through the `Line` component with the chart, to eliminate most of the points. The data I am using has more than 9000 points, which makes the page a little slow when trying to draw them all. I am also manipulating the ticks on the main `Line` chart, so that they don't overlap. 18 | 19 | One of the main issues I had while designing the `Brush`, was how to convert mouse coordinates to `svg` coordinates. For this, I ussually use [this StackOverflow solution](https://stackoverflow.com/questions/10298658/mouse-position-inside-autoscaled-svg) which needs a reference to the `svg` component. Once again, `nivo` does not provides access to it by default. So, just with the `Line` component, I cloned the `SvgWrapper` component, and made two big modifications to it. 20 | 21 | 1. I converted it into a `class` component to take advantage of the `ref` api. 22 | 2. I added a new handler called `getSvgRef`, which returns the `svg` reference dynamically. This handler is provided as a prop inside each of the `SvgWrapper` component children. 23 | 24 | The other issue I had to solve, was how to convert from the `Brush` edge coordinates to index values on the data arrays. Line charts on `nivo` use point scales to go from the data to coordinates. Point scales on `d3` don't have an `invert` method by design. So, I hade to construct a new quantize scale from `d3` to convert from edges to index values. Then I just slice the main data using those calculated indexes. 25 | 26 | ## Running the project 27 | 28 | This project was instantiated with `create-react-app`, so running it is as simple as cloning it and running `yarn install` and the `yarn start`. 29 | 30 | ![Example](./images/example.gif) 31 | 32 | ## Licence 33 | 34 | MIT 35 | -------------------------------------------------------------------------------- /src/App/LineChart/LineChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import uniq from 'lodash/uniq.js'; 3 | import indexOf from 'lodash/indexOf.js'; 4 | import { Line } from '@nivo/line'; 5 | import LineWithBrush from './LineWithBrush.js'; 6 | import compose from 'recompose/compose'; 7 | import pure from 'recompose/pure'; 8 | import withHandlers from 'recompose/withHandlers'; 9 | import withStateHandlers from 'recompose/withStateHandlers'; 10 | import withPropsOnChange from 'recompose/withPropsOnChange'; 11 | import { scaleQuantize } from 'd3-scale'; 12 | import { TICK_WIDTH, POINTS_PER_WIDTH } from './constants.js'; 13 | 14 | var LineChart = ({ 15 | axisBottom = {}, 16 | brushData, 17 | brushOverrides, 18 | visibleData, 19 | height, 20 | onBrush, 21 | tickValues, 22 | width, 23 | ...rest 24 | }) => ( 25 |
26 | 36 | 43 |
44 | ); 45 | 46 | var enhance = compose( 47 | withStateHandlers( 48 | ({ initialMin, initialMax, data }) => ({ 49 | min: initialMin || 0, 50 | max: initialMax || Math.max(data.map(d => d.data.length)) - 1 51 | }), 52 | { 53 | updateValidRange: () => (min, max) => ({ min, max }) 54 | } 55 | ), 56 | withPropsOnChange(['data'], ({ data }) => ({ 57 | xRange: uniq( 58 | data.reduce((acc, d) => acc.concat(d.data.map(({ x }) => x)), []) 59 | ) 60 | })), 61 | withPropsOnChange(['width', 'margin'], ({ width, margin }) => ({ 62 | innerWidth: width - (margin.left || 0) - (margin.right || 0) 63 | })), 64 | withPropsOnChange(['innerWidth', 'xRange'], ({ innerWidth, xRange }) => ({ 65 | invertScale: scaleQuantize() 66 | .domain([0, innerWidth]) 67 | .range(xRange) 68 | })), 69 | withPropsOnChange( 70 | ['initialMin', 'initialMax'], 71 | ({ initialMin, initialMax, invertScale, xRange }) => { 72 | var result = { 73 | initialMinEdge: invertScale.invertExtent(xRange[initialMin])[0], 74 | initialMaxEdge: invertScale.invertExtent(xRange[initialMax])[1] 75 | }; 76 | 77 | return result; 78 | } 79 | ), 80 | withHandlers({ 81 | update: ({ updateValidRange }) => () => { 82 | updateValidRange(this.minEdge, this.maxEdge); 83 | } 84 | }), 85 | withHandlers({ 86 | onBrush: ({ update, invertScale, xRange, data }) => (minEdge, maxEdge) => { 87 | this.minEdge = indexOf(xRange, invertScale(minEdge)); 88 | this.maxEdge = indexOf(xRange, invertScale(maxEdge)); 89 | requestAnimationFrame(update); 90 | } 91 | }), 92 | withPropsOnChange(['innerWidth'], ({ innerWidth }) => ({ 93 | maxPoints: Math.round(innerWidth / POINTS_PER_WIDTH) 94 | })), 95 | withPropsOnChange(['data', 'min', 'max'], ({ data, min, max }) => ({ 96 | visibleData: data.map(d => ({ 97 | ...d, 98 | ...{ data: d.data.slice(min, max) } 99 | })) 100 | })), 101 | withPropsOnChange(['visibleData'], ({ visibleData, maxPoints }) => ({ 102 | drawData: visibleData.map(d => { 103 | if (d.data.length < maxPoints) return d; 104 | 105 | var filterEvery = Math.ceil(d.data.length / maxPoints); 106 | 107 | return { 108 | ...d, 109 | ...{ data: d.data.filter((_, i) => i % filterEvery === 0) } 110 | }; 111 | }) 112 | })), 113 | withPropsOnChange(['data'], ({ data, maxPoints }) => ({ 114 | brushData: data.map(d => { 115 | if (d.data.length < maxPoints) return d; 116 | 117 | var filterEvery = Math.ceil(d.data.length / maxPoints); 118 | 119 | return { 120 | ...d, 121 | ...{ data: d.data.filter((_, i) => i % filterEvery === 0) } 122 | }; 123 | }) 124 | })), 125 | withPropsOnChange(['drawData', 'innerWidth'], ({ drawData, innerWidth }) => { 126 | var xValues = uniq( 127 | drawData 128 | .map(d => d.data.map(({ x }) => x)) 129 | .reduce((acc, data) => acc.concat(data), []) 130 | ); 131 | 132 | var gridWidth = Math.ceil(innerWidth / xValues.length); 133 | var tickDistance = Math.floor(TICK_WIDTH / gridWidth); 134 | 135 | return { 136 | tickValues: 137 | tickDistance === 0 138 | ? xValues 139 | : xValues.filter((_, i) => i % tickDistance === 0) 140 | }; 141 | }), 142 | pure 143 | ); 144 | 145 | var EnhancedLineChart = enhance(LineChart); 146 | EnhancedLineChart.displayName = 'enhanced(LineChart)'; 147 | 148 | export default EnhancedLineChart; 149 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/App/LineChart/Brush/Brush.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import compose from 'recompose/compose'; 3 | import pure from 'recompose/pure'; 4 | import withStateHandlers from 'recompose/withStateHandlers'; 5 | import withHandlers from 'recompose/withHandlers.js'; 6 | import { PADDING } from '../constants.js'; 7 | 8 | var Brush = ({ 9 | width, 10 | height, 11 | margin, 12 | minEdge, 13 | maxEdge, 14 | onMouseDown, 15 | onMouseMove, 16 | onMouseUp 17 | }) => { 18 | return ( 19 | 20 | 31 | 42 | 53 | 65 | 66 | ); 67 | }; 68 | 69 | var enhance = compose( 70 | withStateHandlers( 71 | ({ xScale, initialMinEdge, initialMaxEdge }) => { 72 | var range = xScale.range(); 73 | 74 | return { 75 | minEdge: initialMinEdge || range[0], 76 | maxEdge: initialMaxEdge || range[1], 77 | dragging: false, 78 | dragType: '', 79 | difference: 0 80 | }; 81 | }, 82 | { 83 | setState: ( 84 | { minEdge: _minEdge, maxEdge: _maxEdge, dragType: _dragType, dragging }, 85 | { width, onBrush } 86 | ) => (_state = {}) => { 87 | var { 88 | minEdge, 89 | maxEdge, 90 | difference = 0, 91 | dragging = false, 92 | dragType 93 | } = _state; 94 | 95 | var state = { 96 | difference, 97 | dragging, 98 | minEdge: minEdge !== undefined ? minEdge : _minEdge, 99 | maxEdge: maxEdge !== undefined ? maxEdge : _maxEdge, 100 | dragType: dragType !== undefined ? dragType : _dragType 101 | }; 102 | 103 | if (state.maxEdge > width) { 104 | state.maxEdge = width; 105 | state.minEdge = _minEdge; 106 | } 107 | 108 | if (state.minEdge < 0) { 109 | state.minEdge = 0; 110 | state.maxEdge = _maxEdge; 111 | } 112 | 113 | if (state.minEdge !== _minEdge || state.maxEdge !== _maxEdge) 114 | onBrush(state.minEdge, state.maxEdge); 115 | 116 | return state; 117 | } 118 | } 119 | ), 120 | withHandlers({ 121 | cursorPoint: ({ getSvgRef, margin }) => (clientX, clientY) => { 122 | var svg = getSvgRef(); 123 | var pt = svg.createSVGPoint(); 124 | pt.x = clientX - margin.left; 125 | pt.y = clientY; 126 | var result = pt.matrixTransform(svg.getScreenCTM().inverse()); 127 | return result; 128 | } 129 | }), 130 | withHandlers({ 131 | onMouseDown: ({ 132 | cursorPoint, 133 | setState, 134 | minEdge, 135 | maxEdge, 136 | width 137 | }) => side => e => { 138 | var { x } = cursorPoint(e.clientX, e.clientY); 139 | 140 | var state = { 141 | dragging: true 142 | }; 143 | 144 | if (side === 'both') { 145 | state.dragType = 'both'; 146 | this.center = maxEdge - minEdge; 147 | this.delta = Math.round(Math.abs(x - this.center)); 148 | } 149 | 150 | if (side === 'minEdge' || side === 'maxEdge') { 151 | state.dragType = side; 152 | this.delta = Math.round(side === 'minEdge' ? x - minEdge : x - maxEdge); 153 | } 154 | 155 | if (side === 'new') { 156 | if (x < 0) return; 157 | if (x > width) return; 158 | state.minEdge = x; 159 | state.maxEdge = x; 160 | state.dragType = 'minEdge'; 161 | this.delta = 0; 162 | } 163 | 164 | return setState(state); 165 | }, 166 | onMouseMove: ({ 167 | minEdge, 168 | maxEdge, 169 | dragging, 170 | dragType, 171 | difference, 172 | cursorPoint, 173 | setState 174 | }) => e => { 175 | // Do nothing if not dragging. 176 | if (dragging === false) return; 177 | 178 | var { x } = cursorPoint(e.clientX, e.clientY); 179 | 180 | var state = { 181 | minEdge, 182 | maxEdge, 183 | difference, 184 | dragType, 185 | dragging 186 | }; 187 | 188 | if (dragType === 'both') { 189 | // We need to store the difference to substract it on further calls. 190 | state.difference = this.center - Math.round(x - this.delta); 191 | state.minEdge = minEdge - (state.difference - difference); 192 | state.maxEdge = maxEdge - (state.difference - difference); 193 | } else { 194 | var newEdge = Math.round(x - this.delta); 195 | 196 | if (dragType === 'maxEdge' && newEdge <= minEdge) { 197 | state.minEdge = newEdge; 198 | state.maxEdge = minEdge; 199 | state.dragType = 'minEdge'; 200 | } else if (dragType === 'minEdge' && newEdge >= maxEdge) { 201 | state.minEdge = maxEdge; 202 | state.maxEdge = newEdge; 203 | state.dragType = 'maxEdge'; 204 | } else { 205 | // Store the new dragType value 206 | state[dragType] = newEdge; 207 | } 208 | } 209 | // Flip max and min if their difference is smaller than zero. 210 | if (state.minEdge > state.maxEdge) { 211 | let tmp = state.maxEdge; 212 | state.maxEdge = state.minEdge; 213 | state.minEdge = tmp; 214 | } 215 | 216 | setState(state); 217 | }, 218 | onMouseUp: ({ setState }) => () => { 219 | setState({ dragging: false, difference: 0 }); 220 | } 221 | }), 222 | pure 223 | ); 224 | 225 | var EnhancedBrush = enhance(Brush); 226 | EnhancedBrush.displayName = 'enhance(Brush)'; 227 | 228 | export default EnhancedBrush; 229 | -------------------------------------------------------------------------------- /src/App/LineChart/LineWithBrush.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { sortBy } from 'lodash'; 3 | import { area, line } from 'd3-shape'; 4 | import compose from 'recompose/compose'; 5 | import pure from 'recompose/pure'; 6 | import withPropsOnChange from 'recompose/withPropsOnChange'; 7 | import defaultProps from 'recompose/defaultProps'; 8 | import { curveFromProp } from '@nivo/core'; 9 | import { getInheritedColorGenerator } from '@nivo/core'; 10 | import { withTheme, withColors, withDimensions, withMotion } from '@nivo/core'; 11 | import { Container } from '@nivo/core'; 12 | import { 13 | getScales, 14 | getStackedScales, 15 | generateLines, 16 | generateStackedLines 17 | } from '@nivo/line/lib/compute.js'; 18 | import { CartesianMarkers } from '@nivo/core'; 19 | import { Axes, Grid } from '@nivo/core'; 20 | import { BoxLegendSvg } from '@nivo/legends'; 21 | import LineAreas from '@nivo/line/lib/LineAreas'; 22 | import LineLines from '@nivo/line/lib/LineLines'; 23 | import LineSlices from '@nivo/line/lib/LineSlices'; 24 | import LineDots from '@nivo/line/lib/LineDots'; 25 | import { LinePropTypes, LineDefaultProps } from '@nivo/line/lib/props'; 26 | import Brush from './Brush/'; 27 | import SvgWrapper from './SvgWrapper.js'; 28 | 29 | const Line = ({ 30 | // custom 31 | onBrush, 32 | initialMinEdge, 33 | initialMaxEdge, 34 | 35 | // lines and scales 36 | lines, 37 | lineGenerator, 38 | areaGenerator, 39 | xScale, 40 | yScale, 41 | slices, 42 | 43 | // dimensions 44 | margin, 45 | width, 46 | height, 47 | outerWidth, 48 | outerHeight, 49 | 50 | // axes & grid 51 | axisTop, 52 | axisRight, 53 | axisBottom, 54 | axisLeft, 55 | enableGridX, 56 | enableGridY, 57 | 58 | lineWidth, 59 | enableArea, 60 | areaOpacity, 61 | 62 | // dots 63 | enableDots, 64 | dotSymbol, 65 | dotSize, 66 | dotColor, 67 | dotBorderWidth, 68 | dotBorderColor, 69 | enableDotLabel, 70 | dotLabel, 71 | dotLabelFormat, 72 | dotLabelYOffset, 73 | 74 | // markers 75 | markers, 76 | 77 | // theming 78 | theme, 79 | 80 | // motion 81 | animate, 82 | motionStiffness, 83 | motionDamping, 84 | 85 | // interactivity 86 | isInteractive, 87 | tooltipFormat, 88 | 89 | // stackTooltip 90 | enableStackTooltip, 91 | 92 | legends 93 | }) => { 94 | const motionProps = { 95 | animate, 96 | motionDamping, 97 | motionStiffness 98 | }; 99 | 100 | return ( 101 | 102 | {({ showTooltip, hideTooltip }) => ( 103 | 104 | 112 | 120 | 132 | {enableArea && ( 133 | 139 | )} 140 | 146 | {isInteractive && 147 | enableStackTooltip && ( 148 | 156 | )} 157 | {enableDots && ( 158 | 172 | )} 173 | {legends.map((legend, i) => { 174 | const legendData = lines 175 | .map(line => ({ 176 | label: line.id, 177 | fill: line.color 178 | })) 179 | .reverse(); 180 | 181 | return ( 182 | 189 | ); 190 | })} 191 | 200 | 201 | )} 202 | 203 | ); 204 | }; 205 | 206 | Line.propTypes = LinePropTypes; 207 | 208 | const enhance = compose( 209 | defaultProps(LineDefaultProps), 210 | withTheme(), 211 | withColors(), 212 | withDimensions(), 213 | withMotion(), 214 | withPropsOnChange(['curve', 'height'], ({ curve, height }) => ({ 215 | areaGenerator: area() 216 | .x(d => d.x) 217 | .y0(height) 218 | .y1(d => d.y) 219 | .curve(curveFromProp(curve)), 220 | lineGenerator: line() 221 | .defined(d => d.value !== null) 222 | .x(d => d.x) 223 | .y(d => d.y) 224 | .curve(curveFromProp(curve)) 225 | })), 226 | withPropsOnChange( 227 | ['data', 'stacked', 'width', 'height', 'minY', 'maxY'], 228 | ({ data, stacked, width, height, margin, minY, maxY }) => { 229 | let scales; 230 | const args = { data, width, height, minY, maxY }; 231 | if (stacked === true) { 232 | scales = getStackedScales(args); 233 | } else { 234 | scales = getScales(args); 235 | } 236 | 237 | return { 238 | margin, 239 | width, 240 | height, 241 | ...scales 242 | }; 243 | } 244 | ), 245 | withPropsOnChange( 246 | ['getColor', 'xScale', 'yScale'], 247 | ({ data, stacked, xScale, yScale, getColor }) => { 248 | let lines; 249 | if (stacked === true) { 250 | lines = generateStackedLines(data, xScale, yScale, getColor); 251 | } else { 252 | lines = generateLines(data, xScale, yScale, getColor); 253 | } 254 | 255 | const slices = xScale.domain().map((id, i) => { 256 | let points = sortBy( 257 | lines.map(line => ({ 258 | id: line.id, 259 | value: line.points[i].value, 260 | y: line.points[i].y, 261 | color: line.color 262 | })), 263 | 'y' 264 | ); 265 | 266 | return { 267 | id, 268 | x: xScale(id), 269 | points 270 | }; 271 | }); 272 | 273 | return { lines, slices }; 274 | } 275 | ), 276 | pure 277 | ); 278 | 279 | const EnhancedLine = enhance(Line); 280 | EnhancedLine.displayName = 'enhance(Line)'; 281 | 282 | export default EnhancedLine; 283 | --------------------------------------------------------------------------------