├── 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 |
26 | You need to enable JavaScript to run this app.
27 |
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 | 
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 |
--------------------------------------------------------------------------------