diff --git a/js/package.json b/js/package.json index 326b4fb38..4ffc91004 100644 --- a/js/package.json +++ b/js/package.json @@ -113,6 +113,7 @@ "bignumber.js": "^2.3.0", "blockies": "0.0.2", "bytes": "^2.4.0", + "chart.js": "^2.3.0", "es6-promise": "^3.2.1", "file-saver": "^1.3.3", "format-json": "^1.0.3", @@ -128,12 +129,14 @@ "qs": "^6.3.0", "react": "^15.2.1", "react-addons-css-transition-group": "^15.2.1", + "react-chartjs-2": "^1.5.0", "react-dom": "^15.2.1", "react-redux": "^4.4.5", "react-router": "^2.6.1", "react-router-redux": "^4.0.5", "react-tap-event-plugin": "^1.0.0", "react-tooltip": "^2.0.3", + "recharts": "^0.15.2", "redux": "^3.5.2", "redux-actions": "^0.10.1", "redux-thunk": "^2.1.0", diff --git a/js/src/modals/Transfer/Extras/extras.js b/js/src/modals/Transfer/Extras/extras.js index c5ddd4eac..56904b00a 100644 --- a/js/src/modals/Transfer/Extras/extras.js +++ b/js/src/modals/Transfer/Extras/extras.js @@ -17,6 +17,7 @@ import React, { Component, PropTypes } from 'react'; import Form, { Input } from '../../../ui/Form'; +import GasPriceSelector from '../GasPriceSelector'; import styles from '../transfer.css'; @@ -28,50 +29,83 @@ export default class Extras extends Component { gas: PropTypes.string, gasEst: PropTypes.string, gasError: PropTypes.string, - gasPrice: PropTypes.string, + gasPrice: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object + ]), gasPriceDefault: PropTypes.string, gasPriceError: PropTypes.string, + gasPriceHistogram: PropTypes.object, total: PropTypes.string, totalError: PropTypes.string, onChange: PropTypes.func.isRequired } render () { - const { gas, gasError, gasEst, gasPrice, gasPriceDefault, gasPriceError, total, totalError } = this.props; + const { gas, gasPrice, gasError, gasEst, gasPriceDefault, gasPriceError, gasPriceHistogram, total, totalError } = this.props; + const gasLabel = `gas amount (estimated: ${gasEst})`; const priceLabel = `gas price (current: ${gasPriceDefault})`; return (
+ { this.renderData() } +
-
- +
+
-
- + +
+
+ + + +
+ +
+ +
-
-
- -
+ +
+

+ You can choose the gas price based on the + distribution of recent included transactions' gas prices. + The lower the gas price is, the cheaper the transaction will + be. The higher the gas price is, the faster it should + get mined by the network. +

+ ); } @@ -99,8 +133,8 @@ export default class Extras extends Component { this.props.onChange('gas', event.target.value); } - onEditGasPrice = (event) => { - this.props.onChange('gasPrice', event.target.value); + onEditGasPrice = (event, value) => { + this.props.onChange('gasPrice', value); } onEditData = (event) => { diff --git a/js/src/modals/Transfer/GasPriceSelector/gasPriceSelector.css b/js/src/modals/Transfer/GasPriceSelector/gasPriceSelector.css new file mode 100644 index 000000000..445174c59 --- /dev/null +++ b/js/src/modals/Transfer/GasPriceSelector/gasPriceSelector.css @@ -0,0 +1,17 @@ +/* Copyright 2015, 2016 Ethcore (UK) Ltd. +/* This file is part of Parity. +/* +/* Parity is free software: you can redistribute it and/or modify +/* it under the terms of the GNU General Public License as published by +/* the Free Software Foundation, either version 3 of the License, or +/* (at your option) any later version. +/* +/* Parity is distributed in the hope that it will be useful, +/* but WITHOUT ANY WARRANTY; without even the implied warranty of +/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +/* GNU General Public License for more details. +/* +/* You should have received a copy of the GNU General Public License +/* along with Parity. If not, see . +*/ + diff --git a/js/src/modals/Transfer/GasPriceSelector/gasPriceSelector.js b/js/src/modals/Transfer/GasPriceSelector/gasPriceSelector.js new file mode 100644 index 000000000..0dcee8f9d --- /dev/null +++ b/js/src/modals/Transfer/GasPriceSelector/gasPriceSelector.js @@ -0,0 +1,556 @@ +// Copyright 2015, 2016 Ethcore (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import React, { Component, PropTypes } from 'react'; + +import { + Bar, BarChart, + Rectangle, + Scatter, ScatterChart, + Tooltip, + XAxis, YAxis, + Dot, + ResponsiveContainer +} from 'recharts'; + +import Slider from 'material-ui/Slider'; +import BigNumber from 'bignumber.js'; + +import componentStyles from './gasPriceSelector.css'; +import mainStyles from '../transfer.css'; + +const styles = Object.assign({}, mainStyles, componentStyles); + +const COLORS = { + default: 'rgba(255, 99, 132, 0.2)', + selected: 'rgba(255, 99, 132, 0.5)', + hover: 'rgba(255, 99, 132, 0.15)', + grid: 'rgba(255, 99, 132, 0.5)', + line: 'rgb(255, 99, 132)', + intersection: '#fff' +}; + +const countModifier = (count) => { + const val = count.toNumber ? count.toNumber() : count; + return Math.log10(val + 1) + 0.1; +}; + +class CustomCursor extends Component { + static propTypes = { + x: PropTypes.number, + y: PropTypes.number, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + getIndex: PropTypes.func, + counts: PropTypes.array, + yDomain: PropTypes.array + } + + render () { + const { x, y, width, height, getIndex, counts, yDomain } = this.props; + + const index = getIndex(); + + if (index === -1) { + return null; + } + + const count = countModifier(counts[index]); + const barHeight = (count / yDomain[1]) * (y + height); + + return ( + + + + + ); + } + + onClick = () => { + const { onClick, getIndex } = this.props; + const index = getIndex(); + onClick({ index }); + } +} + +class CustomBar extends Component { + static propTypes = { + selected: PropTypes.number, + x: PropTypes.number, + y: PropTypes.number, + width: PropTypes.number, + height: PropTypes.number, + index: PropTypes.number, + onClick: PropTypes.func + } + + render () { + const { x, y, selected, index, width, height, onClick } = this.props; + + const fill = selected === index + ? COLORS.selected + : COLORS.default; + + const borderWidth = 0.5; + const borderColor = 'rgba(255, 255, 255, 0.5)'; + + return ( + + + + + + + ); + } +} + +class CustomizedShape extends Component { + static propTypes = { + showValue: PropTypes.number.isRequired, + cx: PropTypes.number, + cy: PropTypes.number, + payload: PropTypes.object + } + + render () { + const { cx, cy, showValue, payload } = this.props; + + if (showValue !== payload.y) { + return null; + } + + return ( + + + + + ); + } +} + +class CustomTooltip extends Component { + static propTypes = { + gasPriceHistogram: PropTypes.shape({ + bucketBounds: PropTypes.array.isRequired, + counts: PropTypes.array.isRequired + }).isRequired, + type: PropTypes.string, + payload: PropTypes.array, + label: PropTypes.number, + active: PropTypes.bool + } + + render () { + const { active, label, gasPriceHistogram } = this.props; + + if (!active) { + return null; + } + + const index = label; + + const count = gasPriceHistogram.counts[index]; + const minGasPrice = gasPriceHistogram.bucketBounds[index]; + const maxGasPrice = gasPriceHistogram.bucketBounds[index + 1]; + + return ( +
+

+ { count.toNumber() } transactions + with gas price set from + { minGasPrice.toFormat(0) } + to + { maxGasPrice.toFormat(0) } +

+
+ ); + } +} + +export default class GasPriceSelector extends Component { + static propTypes = { + gasPriceHistogram: PropTypes.shape({ + bucketBounds: PropTypes.array.isRequired, + counts: PropTypes.array.isRequired + }).isRequired, + onChange: PropTypes.func.isRequired, + + gasPrice: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object + ]) + } + + state = { + gasPrice: null, + sliderValue: 0.5, + selectedIndex: 0, + + chartData: { + values: [], + xDomain: [], + yDomain: [], + N: 0 + } + } + + componentWillMount () { + this.computeCharts(); + this.setGasPrice(); + } + + componentWillReceiveProps (nextProps) { + if (nextProps.gasPrice !== this.props.gasPrice) { + this.setGasPrice(nextProps); + } + } + + componentWillUpdate (nextProps, nextState) { + if (Math.floor(nextState.sliderValue) !== Math.floor(this.state.sliderValue)) { + this.updateSelectedBarChart(nextState); + } + } + + render () { + return ( +
+ { this.renderChart() } + { this.renderSlider() } +
+ ); + } + + renderSlider () { + const { sliderValue } = this.state; + + return (
+ +
); + } + + renderChart () { + const { gasPriceHistogram } = this.props; + const { chartData, sliderValue, selectedIndex } = this.state; + + if (chartData.values.length === 0) { + return null; + } + + const height = 300; + const countIndex = Math.max(0, Math.min(selectedIndex, gasPriceHistogram.counts.length - 1)); + const selectedCount = countModifier(gasPriceHistogram.counts[countIndex]); + + return (
+
+
+ + + } + line + isAnimationActive={ false } + /> + + + + + +
+ +
+ + + } + /> + + } + /> + + + + + +
+
+
); + } + + renderCustomCursor = () => { + const { gasPriceHistogram } = this.props; + const { chartData } = this.state; + + return ( + + ); + } + + getBarHoverIndex = () => { + const { barChart } = this.refs; + + if (!barChart || !barChart.state) { + return -1; + } + + return barChart.state.activeTooltipIndex; + } + + computeChartsData () { + const { gasPriceChartData } = this.state; + const { gasPriceHistogram } = this.props; + + const values = gasPriceChartData + .map((value, index) => ({ value, index })); + + const N = values.length - 1; + const maxGasCounts = countModifier( + gasPriceHistogram + .counts + .reduce((max, count) => count.greaterThan(max) ? count : max, 0) + ); + + const xDomain = [0, N]; + const yDomain = [0, maxGasCounts * 1.1]; + + const chartData = { + values, N, + xDomain, yDomain + }; + + this.setState({ chartData }, () => { + this.updateSelectedBarChart(); + }); + } + + computeCharts (props = this.props) { + const { gasPriceHistogram } = props; + + const gasPriceChartData = gasPriceHistogram + .counts + .map(count => countModifier(count)); + + this.setState( + { gasPriceChartData }, + () => this.computeChartsData() + ); + } + + updateSelectedBarChart (state = this.state) { + } + + setGasPrice (props = this.props) { + const { gasPrice, gasPriceHistogram } = props; + + // If no gas price yet... + if (!gasPrice) { + return this.setSliderValue(0.5); + } + + const bnGasPrice = (typeof gasPrice === 'string') + ? new BigNumber(gasPrice) + : gasPrice; + + // If gas price hasn't changed + if (this.state.gasPrice && bnGasPrice.equals(this.state.gasPrice)) { + return; + } + + const gasPrices = gasPriceHistogram.bucketBounds; + const startIndex = gasPrices + .findIndex(price => price.greaterThan(bnGasPrice)) - 1; + + // gasPrice Lower than the max in histogram + if (startIndex === -1) { + return this.setSliderValue(0, bnGasPrice); + } + + // gasPrice Greater than the max in histogram + if (startIndex === -2) { + return this.setSliderValue(1, bnGasPrice); + } + + const priceA = gasPrices[startIndex]; + const priceB = gasPrices[startIndex + 1]; + + const sliderValueDec = bnGasPrice + .minus(priceA) + .dividedBy(priceB.minus(priceA)) + .toNumber(); + + const sliderValue = (startIndex + sliderValueDec) / (gasPrices.length - 1); + this.setSliderValue(sliderValue, bnGasPrice); + } + + setSliderValue (value, gasPrice = this.state.gasPrice) { + const { gasPriceHistogram } = this.props; + + const N = gasPriceHistogram.bucketBounds.length - 1; + + const sliderValue = Math.max(0, Math.min(value, 1)); + const selectedIndex = Math.floor(sliderValue * N); + + this.setState({ sliderValue, gasPrice, selectedIndex }); + } + + onBarChartMouseUp = (event) => { + console.log(event); + } + + onClickGasPrice = (bar) => { + const { index } = bar; + + const ratio = (index + 0.5) / (this.state.chartData.N + 1); + + this.onEditGasPriceSlider(null, ratio); + } + + onEditGasPriceSlider = (event, sliderValue) => { + const { gasPriceHistogram } = this.props; + + const gasPrices = gasPriceHistogram.bucketBounds; + const N = gasPrices.length - 1; + const gasPriceAIdx = Math.floor(sliderValue * N); + const gasPriceBIdx = gasPriceAIdx + 1; + + if (gasPriceBIdx === N + 1) { + const gasPrice = gasPrices[gasPriceAIdx]; + this.props.onChange(event, gasPrice); + return; + } + + const gasPriceA = gasPrices[gasPriceAIdx]; + const gasPriceB = gasPrices[gasPriceBIdx]; + + const mult = Math.round((sliderValue % 1) * 100) / 100; + const gasPrice = gasPriceA.plus(gasPriceB.minus(gasPriceA).times(mult)); + + this.setSliderValue(sliderValue, gasPrice); + this.props.onChange(event, gasPrice); + } +} diff --git a/js/src/modals/Transfer/GasPriceSelector/index.js b/js/src/modals/Transfer/GasPriceSelector/index.js new file mode 100644 index 000000000..958075867 --- /dev/null +++ b/js/src/modals/Transfer/GasPriceSelector/index.js @@ -0,0 +1,17 @@ +// Copyright 2015, 2016 Ethcore (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +export default from './gasPriceSelector'; diff --git a/js/src/modals/Transfer/transfer.css b/js/src/modals/Transfer/transfer.css index 720cd694b..89b58666e 100644 --- a/js/src/modals/Transfer/transfer.css +++ b/js/src/modals/Transfer/transfer.css @@ -25,6 +25,13 @@ position: relative; } +.row { + display: flex; + flex-wrap: wrap; + position: relative; + flex-direction: column; +} + .columns>div { flex: 0 1 50%; width: 50%; @@ -131,3 +138,16 @@ .inputoverride { padding-top: 24px !important; } + +.contentTitle { + font-size: 1.2rem; +} + +.chart { + position: absolute; + width: 100%; +} + +.gasPriceDesc { + font-size: 0.9em; +} diff --git a/js/src/modals/Transfer/transfer.js b/js/src/modals/Transfer/transfer.js index 0ffad525c..192b2f460 100644 --- a/js/src/modals/Transfer/transfer.js +++ b/js/src/modals/Transfer/transfer.js @@ -62,6 +62,7 @@ export default class Transfer extends Component { gasEst: '0', gasError: null, gasPrice: DEFAULT_GASPRICE, + gasPriceHistogram: {}, gasPriceError: null, recipient: '', recipientError: ERRORS.requireRecipient, @@ -89,7 +90,9 @@ export default class Transfer extends Component { current={ stage } steps={ extras ? STAGES_EXTRA : STAGES_BASIC } waiting={ extras ? [2] : [1] } - visible> + visible + scroll + > { this.renderPage() } ); @@ -169,6 +172,10 @@ export default class Transfer extends Component { } renderExtrasPage () { + if (!this.state.gasPriceHistogram) { + return null; + } + return ( @@ -581,12 +589,16 @@ export default class Transfer extends Component { getDefaults = () => { const { api } = this.context; - api.eth - .gasPrice() - .then((gasPrice) => { + Promise + .all([ + api.ethcore.gasPriceHistogram(), + api.eth.gasPrice() + ]) + .then(([gasPriceHistogram, gasPrice]) => { this.setState({ gasPrice: gasPrice.toString(), - gasPriceDefault: gasPrice.toFormat() + gasPriceDefault: gasPrice.toFormat(), + gasPriceHistogram }, this.recalculate); }) .catch((error) => { diff --git a/js/src/views/Account/account.js b/js/src/views/Account/account.js index ecdf1cfd8..902e3e7c1 100644 --- a/js/src/views/Account/account.js +++ b/js/src/views/Account/account.js @@ -81,11 +81,18 @@ class Account extends Component { } renderActionbar () { + const { address } = this.props.params; + const { balances } = this.props; + const balance = balances[address]; + + const showTransferButton = !!(balance && balance.tokens); + const buttons = [