├── .gitignore ├── readmeGIF.gif ├── src ├── index.css ├── index.js ├── ToolTip.css ├── App.css ├── LineChart.css ├── InfoBox.css ├── ToolTip.js ├── InfoBox.js ├── App.js ├── registerServiceWorker.js └── LineChart.js ├── public ├── favicon.ico ├── manifest.json └── index.html ├── readme.md ├── package.json └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /readmeGIF.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmorelli25/interactive-bitcoin-price-chart/HEAD/readmeGIF.gif -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 50px; 3 | padding: 0; 4 | font-family: 'Oxygen', sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmorelli25/interactive-bitcoin-price-chart/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import registerServiceWorker from './registerServiceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | registerServiceWorker(); 9 | -------------------------------------------------------------------------------- /src/ToolTip.css: -------------------------------------------------------------------------------- 1 | .hover { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | 6 | position: absolute; 7 | padding: 5px; 8 | border-radius: 4px; 9 | background-color: #2196F3; 10 | color: white; 11 | } 12 | 13 | .hover .price { 14 | font-weight: 700; 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | justify-content: center; 4 | } 5 | 6 | h1 { 7 | padding: 0; 8 | margin: 0; 9 | font-weight: 700; 10 | color: #757575; 11 | } 12 | 13 | #coindesk { 14 | font-weight: 300; 15 | } 16 | 17 | #coindesk a { 18 | text-decoration: none; 19 | color: inherit; 20 | } 21 | 22 | .popup { 23 | min-height: 50px; 24 | } 25 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Interactive Bitcoin Price Chart *built with React & SVG* 2 | --- 3 | 4 | * [Live Demo Here](https://interactive-bitcoin-price-chart-yenswahhtb.now.sh) 5 | * [Project Walk Through / Tutorial](https://codeburst.io/how-i-built-an-interactive-30-day-bitcoin-price-graph-with-react-and-an-api-6fe551c2ab1d) 6 | --- 7 | 8 | ![React SVG Gif](https://github.com/bmorelli25/react-svg-line-chart/blob/master/4_interactive_bitcoin_chart/readmeGIF.gif?raw=true) 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/LineChart.css: -------------------------------------------------------------------------------- 1 | .linechart { 2 | padding: 8px; 3 | } 4 | .linechart_path { 5 | stroke-width: 3; 6 | fill: none; 7 | } 8 | 9 | .linechart_axis { 10 | stroke: #bdc3c7; 11 | } 12 | 13 | .linechart_point { 14 | fill: #fff; 15 | stroke-width: 2; 16 | } 17 | 18 | .linechart_area { 19 | padding: 8px; 20 | fill: #64B5F6; 21 | stroke: none; 22 | opacity: .4; 23 | } 24 | 25 | .linechart_label { 26 | fill: #64B5F6; 27 | font-weight: 700; 28 | } 29 | 30 | .hoverLine { 31 | stroke: #7D95B6; 32 | stroke-width: 1; 33 | } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "interactive-bitcoin-price-chart", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "moment": "^2.18.1", 6 | "react": "^15.6.1", 7 | "react-dom": "^15.6.1", 8 | "serve": "^6.0.3" 9 | }, 10 | "devDependencies": { 11 | "react-scripts": "1.0.10" 12 | }, 13 | "scripts": { 14 | "start": "react-scripts start", 15 | "now-start": "serve build/", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test --env=jsdom", 18 | "eject": "react-scripts eject" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/InfoBox.css: -------------------------------------------------------------------------------- 1 | #data-container { 2 | width: 100%; 3 | display: flex; 4 | justify-content: center; 5 | } 6 | 7 | .box { 8 | width: 250px; 9 | 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | margin: 10px 0; 14 | } 15 | 16 | .heading { 17 | font-size: 2.5em; 18 | color: #2196F3; 19 | } 20 | 21 | .subtext { 22 | color: #64B5F6; 23 | } 24 | 25 | #left { 26 | margin-right: 48px; 27 | } 28 | 29 | #middle { 30 | padding: 0 48px; 31 | border-left: 1px solid #DAE1E9; 32 | border-right: 1px solid #DAE1E9; 33 | } 34 | 35 | #right { 36 | margin-left: 48px; 37 | } 38 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | React-SVG-Meter 12 | 13 | 14 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/ToolTip.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './ToolTip.css'; 3 | 4 | class ToolTip extends Component { 5 | 6 | render() { 7 | const {hoverLoc, activePoint} = this.props; 8 | const svgLocation = document.getElementsByClassName("linechart")[0].getBoundingClientRect(); 9 | 10 | let placementStyles = {}; 11 | let width = 100; 12 | placementStyles.width = width + 'px'; 13 | placementStyles.left = hoverLoc + svgLocation.left - (width/2); 14 | 15 | return ( 16 |
17 |
{ activePoint.d }
18 |
{activePoint.p }
19 |
20 | ) 21 | } 22 | } 23 | 24 | export default ToolTip; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Brandon Morelli 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/InfoBox.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import moment from 'moment'; 3 | import './InfoBox.css'; 4 | 5 | class InfoBox extends Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | currentPrice: null, 10 | monthChangeD: null, 11 | monthChangeP: null, 12 | updatedAt: null 13 | } 14 | } 15 | componentDidMount(){ 16 | this.getData = () => { 17 | const {data} = this.props; 18 | const url = 'https://api.coindesk.com/v1/bpi/currentprice.json'; 19 | 20 | fetch(url).then(r => r.json()) 21 | .then((bitcoinData) => { 22 | const price = bitcoinData.bpi.USD.rate_float; 23 | const change = price - data[0].y; 24 | const changeP = (price - data[0].y) / data[0].y * 100; 25 | 26 | this.setState({ 27 | currentPrice: bitcoinData.bpi.USD.rate_float, 28 | monthChangeD: change.toLocaleString('us-EN',{ style: 'currency', currency: 'USD' }), 29 | monthChangeP: changeP.toFixed(2) + '%', 30 | updatedAt: bitcoinData.time.updated 31 | }) 32 | }) 33 | .catch((e) => { 34 | console.log(e); 35 | }); 36 | } 37 | this.getData(); 38 | this.refresh = setInterval(() => this.getData(), 90000); 39 | } 40 | componentWillUnmount(){ 41 | clearInterval(this.refresh); 42 | } 43 | render(){ 44 | return ( 45 |
46 | { this.state.currentPrice ? 47 |
48 |
{this.state.currentPrice.toLocaleString('us-EN',{ style: 'currency', currency: 'USD' })}
49 |
{'Updated ' + moment(this.state.updatedAt ).fromNow()}
50 |
51 | : null} 52 | { this.state.currentPrice ? 53 |
54 |
{this.state.monthChangeD}
55 |
Change Since Last Month (USD)
56 |
57 | : null} 58 | 62 | 63 |
64 | ); 65 | } 66 | } 67 | 68 | export default InfoBox; 69 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import moment from 'moment'; 3 | import './App.css'; 4 | import LineChart from './LineChart'; 5 | import ToolTip from './ToolTip'; 6 | import InfoBox from './InfoBox'; 7 | 8 | class App extends Component { 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | fetchingData: true, 13 | data: null, 14 | hoverLoc: null, 15 | activePoint: null 16 | } 17 | } 18 | handleChartHover(hoverLoc, activePoint){ 19 | this.setState({ 20 | hoverLoc: hoverLoc, 21 | activePoint: activePoint 22 | }) 23 | } 24 | componentDidMount(){ 25 | const getData = () => { 26 | const url = 'https://api.coindesk.com/v1/bpi/historical/close.json'; 27 | 28 | fetch(url).then( r => r.json()) 29 | .then((bitcoinData) => { 30 | const sortedData = []; 31 | let count = 0; 32 | for (let date in bitcoinData.bpi){ 33 | sortedData.push({ 34 | d: moment(date).format('MMM DD'), 35 | p: bitcoinData.bpi[date].toLocaleString('us-EN',{ style: 'currency', currency: 'USD' }), 36 | x: count, //previous days 37 | y: bitcoinData.bpi[date] // numerical price 38 | }); 39 | count++; 40 | } 41 | this.setState({ 42 | data: sortedData, 43 | fetchingData: false 44 | }) 45 | }) 46 | .catch((e) => { 47 | console.log(e); 48 | }); 49 | } 50 | getData(); 51 | } 52 | render() { 53 | return ( 54 | 55 |
56 |
57 |

30 Day Bitcoin Price Chart

58 |
59 |
60 | { !this.state.fetchingData ? 61 | 62 | : null } 63 |
64 |
65 |
66 | {this.state.hoverLoc ? : null} 67 |
68 |
69 |
70 |
71 | { !this.state.fetchingData ? 72 | this.handleChartHover(a,b) }/> 73 | : null } 74 |
75 |
76 |
77 |
Powered by CoinDesk
78 |
79 |
80 | 81 | ); 82 | } 83 | } 84 | 85 | export default App; 86 | -------------------------------------------------------------------------------- /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 | // Is not local host. Just register service worker 37 | registerValidSW(swUrl); 38 | } else { 39 | // This is running on localhost. Lets check if a service worker still exists or not. 40 | checkValidServiceWorker(swUrl); 41 | } 42 | }); 43 | } 44 | } 45 | 46 | function registerValidSW(swUrl) { 47 | navigator.serviceWorker 48 | .register(swUrl) 49 | .then(registration => { 50 | registration.onupdatefound = () => { 51 | const installingWorker = registration.installing; 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the old content will have been purged and 56 | // the fresh content will have been added to the cache. 57 | // It's the perfect time to display a "New content is 58 | // available; please refresh." message in your web app. 59 | console.log('New content is available; please refresh.'); 60 | } else { 61 | // At this point, everything has been precached. 62 | // It's the perfect time to display a 63 | // "Content is cached for offline use." message. 64 | console.log('Content is cached for offline use.'); 65 | } 66 | } 67 | }; 68 | }; 69 | }) 70 | .catch(error => { 71 | console.error('Error during service worker registration:', error); 72 | }); 73 | } 74 | 75 | function checkValidServiceWorker(swUrl) { 76 | // Check if the service worker can be found. If it can't reload the page. 77 | fetch(swUrl) 78 | .then(response => { 79 | // Ensure service worker exists, and that we really are getting a JS file. 80 | if ( 81 | response.status === 404 || 82 | response.headers.get('content-type').indexOf('javascript') === -1 83 | ) { 84 | // No service worker found. Probably a different app. Reload the page. 85 | navigator.serviceWorker.ready.then(registration => { 86 | registration.unregister().then(() => { 87 | window.location.reload(); 88 | }); 89 | }); 90 | } else { 91 | // Service worker found. Proceed as normal. 92 | registerValidSW(swUrl); 93 | } 94 | }) 95 | .catch(() => { 96 | console.log( 97 | 'No internet connection found. App is running in offline mode.' 98 | ); 99 | }); 100 | } 101 | 102 | export function unregister() { 103 | if ('serviceWorker' in navigator) { 104 | navigator.serviceWorker.ready.then(registration => { 105 | registration.unregister(); 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/LineChart.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from "react"; 2 | import "./LineChart.css"; 3 | 4 | class LineChart extends Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { 8 | hoverLoc: null, 9 | activePoint: null 10 | } 11 | } 12 | // GET X & Y || MAX & MIN 13 | getX(){ 14 | const {data} = this.props; 15 | return { 16 | min: data[0].x, 17 | max: data[data.length - 1].x 18 | } 19 | } 20 | getY(){ 21 | const {data} = this.props; 22 | return { 23 | min: data.reduce((min, p) => p.y < min ? p.y : min, data[0].y), 24 | max: data.reduce((max, p) => p.y > max ? p.y : max, data[0].y) 25 | } 26 | } 27 | // GET SVG COORDINATES 28 | getSvgX(x) { 29 | const {svgWidth, yLabelSize} = this.props; 30 | return yLabelSize + (x / this.getX().max * (svgWidth - yLabelSize)); 31 | } 32 | getSvgY(y) { 33 | const {svgHeight, xLabelSize} = this.props; 34 | const gY = this.getY(); 35 | return ((svgHeight - xLabelSize) * gY.max - (svgHeight - xLabelSize) * y) / (gY.max - gY.min); 36 | } 37 | // BUILD SVG PATH 38 | makePath() { 39 | const {data, color} = this.props; 40 | let pathD = "M " + this.getSvgX(data[0].x) + " " + this.getSvgY(data[0].y) + " "; 41 | 42 | pathD += data.map((point, i) => { 43 | return "L " + this.getSvgX(point.x) + " " + this.getSvgY(point.y) + " "; 44 | }).join(""); 45 | 46 | return ( 47 | 48 | ); 49 | } 50 | // BUILD SHADED AREA 51 | makeArea() { 52 | const {data} = this.props; 53 | let pathD = "M " + this.getSvgX(data[0].x) + " " + this.getSvgY(data[0].y) + " "; 54 | 55 | pathD += data.map((point, i) => { 56 | return "L " + this.getSvgX(point.x) + " " + this.getSvgY(point.y) + " "; 57 | }).join(""); 58 | 59 | const x = this.getX(); 60 | const y = this.getY(); 61 | pathD += "L " + this.getSvgX(x.max) + " " + this.getSvgY(y.min) + " " 62 | + "L " + this.getSvgX(x.min) + " " + this.getSvgY(y.min) + " "; 63 | 64 | return 65 | } 66 | // BUILD GRID AXIS 67 | makeAxis() { 68 | const {yLabelSize} = this.props; 69 | const x = this.getX(); 70 | const y = this.getY(); 71 | 72 | return ( 73 | 74 | 78 | 82 | 83 | ); 84 | } 85 | makeLabels(){ 86 | const {svgHeight, svgWidth, xLabelSize, yLabelSize} = this.props; 87 | const padding = 5; 88 | return( 89 | 90 | {/* Y AXIS LABELS */} 91 | 92 | {this.getY().max.toLocaleString('us-EN',{ style: 'currency', currency: 'USD' })} 93 | 94 | 95 | {this.getY().min.toLocaleString('us-EN',{ style: 'currency', currency: 'USD' })} 96 | 97 | {/* X AXIS LABELS */} 98 | 99 | { this.props.data[0].d } 100 | 101 | 102 | { this.props.data[this.props.data.length - 1].d } 103 | 104 | 105 | ) 106 | } 107 | // FIND CLOSEST POINT TO MOUSE 108 | getCoords(e){ 109 | const {svgWidth, data, yLabelSize} = this.props; 110 | const svgLocation = document.getElementsByClassName("linechart")[0].getBoundingClientRect(); 111 | const adjustment = (svgLocation.width - svgWidth) / 2; //takes padding into consideration 112 | const relativeLoc = e.clientX - svgLocation.left - adjustment; 113 | 114 | let svgData = []; 115 | data.map((point, i) => { 116 | svgData.push({ 117 | svgX: this.getSvgX(point.x), 118 | svgY: this.getSvgY(point.y), 119 | d: point.d, 120 | p: point.p 121 | }); 122 | }); 123 | 124 | let closestPoint = {}; 125 | for(let i = 0, c = 500; i < svgData.length; i++){ 126 | if ( Math.abs(svgData[i].svgX - this.state.hoverLoc) <= c ){ 127 | c = Math.abs(svgData[i].svgX - this.state.hoverLoc); 128 | closestPoint = svgData[i]; 129 | } 130 | } 131 | 132 | if(relativeLoc - yLabelSize < 0){ 133 | this.stopHover(); 134 | } else { 135 | this.setState({ 136 | hoverLoc: relativeLoc, 137 | activePoint: closestPoint 138 | }) 139 | this.props.onChartHover(relativeLoc, closestPoint); 140 | } 141 | } 142 | // STOP HOVER 143 | stopHover(){ 144 | this.setState({hoverLoc: null, activePoint: null}); 145 | this.props.onChartHover(null, null); 146 | } 147 | // MAKE ACTIVE POINT 148 | makeActivePoint(){ 149 | const {color, pointRadius} = this.props; 150 | return ( 151 | 158 | ); 159 | } 160 | // MAKE HOVER LINE 161 | createLine(){ 162 | const {svgHeight, xLabelSize} = this.props; 163 | return ( 164 | 167 | ) 168 | } 169 | 170 | render() { 171 | const {svgHeight, svgWidth} = this.props; 172 | 173 | return ( 174 | this.stopHover() } 176 | onMouseMove={ (e) => this.getCoords(e) } > 177 | 178 | {this.makeAxis()} 179 | {this.makePath()} 180 | {this.makeArea()} 181 | {this.makeLabels()} 182 | {this.state.hoverLoc ? this.createLine() : null} 183 | {this.state.hoverLoc ? this.makeActivePoint() : null} 184 | 185 | 186 | ); 187 | } 188 | } 189 | // DEFAULT PROPS 190 | LineChart.defaultProps = { 191 | data: [], 192 | color: '#2196F3', 193 | pointRadius: 5, 194 | svgHeight: 300, 195 | svgWidth: 900, 196 | xLabelSize: 20, 197 | yLabelSize: 80 198 | } 199 | 200 | export default LineChart; 201 | --------------------------------------------------------------------------------