Graphical gas price selection (#2898)

* Added gasPriceStatistics

* WIP graph fas price stats (#2142)

* Chart to select gas price in Extra Tx (#2142)

* Gas Selection UI

* Gas Price Selection: better UI (right octiles, point on graph) (#2142)

* Gas Price Selection chart update using D3 (#2142)

* Working UI, more fluid... (#2142)

* Using the new gasPriceHistogram Call: display histogram (#2142)

* Code Clean

* Updated gas Selection explaination

* PR grumble // Gas Price Selector (#2898)

* Fixing linting issues
This commit is contained in:
Nicolas Gotchac 2016-11-01 15:04:51 +01:00 committed by Jaco Greeff
parent 3b6c969398
commit 84ca3d7a7d
8 changed files with 698 additions and 32 deletions

View File

@ -113,6 +113,7 @@
"bignumber.js": "^2.3.0", "bignumber.js": "^2.3.0",
"blockies": "0.0.2", "blockies": "0.0.2",
"bytes": "^2.4.0", "bytes": "^2.4.0",
"chart.js": "^2.3.0",
"es6-promise": "^3.2.1", "es6-promise": "^3.2.1",
"file-saver": "^1.3.3", "file-saver": "^1.3.3",
"format-json": "^1.0.3", "format-json": "^1.0.3",
@ -128,12 +129,14 @@
"qs": "^6.3.0", "qs": "^6.3.0",
"react": "^15.2.1", "react": "^15.2.1",
"react-addons-css-transition-group": "^15.2.1", "react-addons-css-transition-group": "^15.2.1",
"react-chartjs-2": "^1.5.0",
"react-dom": "^15.2.1", "react-dom": "^15.2.1",
"react-redux": "^4.4.5", "react-redux": "^4.4.5",
"react-router": "^2.6.1", "react-router": "^2.6.1",
"react-router-redux": "^4.0.5", "react-router-redux": "^4.0.5",
"react-tap-event-plugin": "^1.0.0", "react-tap-event-plugin": "^1.0.0",
"react-tooltip": "^2.0.3", "react-tooltip": "^2.0.3",
"recharts": "^0.15.2",
"redux": "^3.5.2", "redux": "^3.5.2",
"redux-actions": "^0.10.1", "redux-actions": "^0.10.1",
"redux-thunk": "^2.1.0", "redux-thunk": "^2.1.0",

View File

@ -17,6 +17,7 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import Form, { Input } from '../../../ui/Form'; import Form, { Input } from '../../../ui/Form';
import GasPriceSelector from '../GasPriceSelector';
import styles from '../transfer.css'; import styles from '../transfer.css';
@ -28,50 +29,83 @@ export default class Extras extends Component {
gas: PropTypes.string, gas: PropTypes.string,
gasEst: PropTypes.string, gasEst: PropTypes.string,
gasError: PropTypes.string, gasError: PropTypes.string,
gasPrice: PropTypes.string, gasPrice: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object
]),
gasPriceDefault: PropTypes.string, gasPriceDefault: PropTypes.string,
gasPriceError: PropTypes.string, gasPriceError: PropTypes.string,
gasPriceHistogram: PropTypes.object,
total: PropTypes.string, total: PropTypes.string,
totalError: PropTypes.string, totalError: PropTypes.string,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
} }
render () { 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 gasLabel = `gas amount (estimated: ${gasEst})`;
const priceLabel = `gas price (current: ${gasPriceDefault})`; const priceLabel = `gas price (current: ${gasPriceDefault})`;
return ( return (
<Form> <Form>
{ this.renderData() } { this.renderData() }
<div className={ styles.columns }> <div className={ styles.columns }>
<div> <div style={ { flex: 65 } }>
<Input <GasPriceSelector
label={ gasLabel } gasPriceHistogram={ gasPriceHistogram }
hint='the amount of gas to use for the transaction' gasPrice={ gasPrice }
error={ gasError } onChange={ this.onEditGasPrice }
value={ gas } />
onChange={ this.onEditGas } />
</div> </div>
<div>
<Input <div
label={ priceLabel } className={ styles.row }
hint='the price of gas to use for the transaction' style={ {
error={ gasPriceError } flex: 35, paddingLeft: '1rem',
value={ gasPrice } justifyContent: 'space-around',
onChange={ this.onEditGasPrice } /> paddingBottom: 12
} }
>
<div className={ styles.row }>
<Input
label={ gasLabel }
hint='the amount of gas to use for the transaction'
error={ gasError }
value={ gas }
onChange={ this.onEditGas } />
<Input
label={ priceLabel }
hint='the price of gas to use for the transaction'
error={ gasPriceError }
value={ (gasPrice || '').toString() }
onChange={ this.onEditGasPrice } />
</div>
<div className={ styles.row }>
<Input
disabled
label='total transaction amount'
hint='the total amount of the transaction'
error={ totalError }
value={ `${total} ETH` } />
</div>
</div> </div>
</div> </div>
<div className={ styles.columns }>
<div> <div>
<Input <p className={ styles.gasPriceDesc }>
disabled You can choose the gas price based on the
label='total transaction amount' distribution of recent included transactions' gas prices.
hint='the total amount of the transaction' The lower the gas price is, the cheaper the transaction will
error={ totalError } be. The higher the gas price is, the faster it should
value={ `${total} ETH` } /> get mined by the network.
</div> </p>
</div> </div>
</Form> </Form>
); );
} }
@ -99,8 +133,8 @@ export default class Extras extends Component {
this.props.onChange('gas', event.target.value); this.props.onChange('gas', event.target.value);
} }
onEditGasPrice = (event) => { onEditGasPrice = (event, value) => {
this.props.onChange('gasPrice', event.target.value); this.props.onChange('gasPrice', value);
} }
onEditData = (event) => { onEditData = (event) => {

View File

@ -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 <http://www.gnu.org/licenses/>.
*/

View File

@ -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 <http://www.gnu.org/licenses/>.
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 (
<g>
<Rectangle
x={ x }
y={ 0 }
width={ width }
height={ height + y }
fill='transparent'
onClick={ this.onClick }
/>
<Rectangle
x={ x }
y={ y + (height - barHeight) }
width={ width }
height={ barHeight }
fill={ COLORS.hover }
onClick={ this.onClick }
/>
</g>
);
}
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 (
<g>
<Rectangle
x={ x - borderWidth }
y={ y }
width={ borderWidth }
height={ height }
fill={ borderColor }
/>
<Rectangle
x={ x + width }
y={ y }
width={ borderWidth }
height={ height }
fill={ borderColor }
/>
<Rectangle
x={ x - borderWidth }
y={ y - borderWidth }
width={ width + borderWidth * 2 }
height={ borderWidth }
fill={ borderColor }
/>
<Rectangle
x={ x }
y={ y }
width={ width }
height={ height }
fill={ fill }
onClick={ onClick }
/>
</g>
);
}
}
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 (
<g>
<Dot
style={ { fill: 'white' } }
cx={ cx }
cy={ cy }
r={ 5 }
/>
<Dot
style={ { fill: 'rgb(255, 99, 132)' } }
cx={ cx }
cy={ cy }
r={ 3 }
/>
</g>
);
}
}
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 (
<div>
<p className='label'>
{ count.toNumber() } transactions
with gas price set from
<span> { minGasPrice.toFormat(0) } </span>
to
<span> { maxGasPrice.toFormat(0) } </span>
</p>
</div>
);
}
}
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 (
<div>
{ this.renderChart() }
{ this.renderSlider() }
</div>
);
}
renderSlider () {
const { sliderValue } = this.state;
return (<div className={ styles.columns }>
<Slider
min={ 0 }
max={ 1 }
value={ sliderValue }
onChange={ this.onEditGasPriceSlider }
style={ {
flex: 1,
padding: '0 0.3em'
} }
sliderStyle={ {
marginBottom: 12
} }
/>
</div>);
}
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 (<div className={ styles.columns }>
<div style={ { flex: 1, height } }>
<div className={ styles.chart }>
<ResponsiveContainer
height={ height }
>
<ScatterChart
margin={ { top: 0, right: 0, left: 0, bottom: 0 } }
>
<Scatter
data={ [
{ x: sliderValue, y: 0 },
{ x: sliderValue, y: selectedCount },
{ x: sliderValue, y: chartData.yDomain[1] }
] }
shape={ <CustomizedShape showValue={ selectedCount } /> }
line
isAnimationActive={ false }
/>
<XAxis
hide
height={ 0 }
dataKey='x'
domain={ [0, 1] }
/>
<YAxis
hide
width={ 0 }
dataKey='y'
domain={ chartData.yDomain }
/>
</ScatterChart>
</ResponsiveContainer>
</div>
<div className={ styles.chart }>
<ResponsiveContainer
height={ height }
>
<BarChart
data={ chartData.values }
margin={ { top: 0, right: 0, left: 0, bottom: 0 } }
barCategoryGap={ 1 }
ref='barChart'
>
<Bar
dataKey='value'
stroke={ COLORS.line }
onClick={ this.onClickGasPrice }
shape={ <CustomBar selected={ selectedIndex } onClick={ this.onClickGasPrice } /> }
/>
<Tooltip
wrapperStyle={ {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
padding: '0 0.5em',
fontSize: '0.9em'
} }
cursor={ this.renderCustomCursor() }
content={ <CustomTooltip gasPriceHistogram={ gasPriceHistogram } /> }
/>
<XAxis
hide
dataKey='index'
type='category'
domain={ chartData.xDomain }
/>
<YAxis
hide
type='number'
domain={ chartData.yDomain }
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>);
}
renderCustomCursor = () => {
const { gasPriceHistogram } = this.props;
const { chartData } = this.state;
return (
<CustomCursor
getIndex={ this.getBarHoverIndex }
onClick={ this.onClickGasPrice }
counts={ gasPriceHistogram.counts }
yDomain={ chartData.yDomain }
/>
);
}
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);
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
export default from './gasPriceSelector';

View File

@ -25,6 +25,13 @@
position: relative; position: relative;
} }
.row {
display: flex;
flex-wrap: wrap;
position: relative;
flex-direction: column;
}
.columns>div { .columns>div {
flex: 0 1 50%; flex: 0 1 50%;
width: 50%; width: 50%;
@ -131,3 +138,16 @@
.inputoverride { .inputoverride {
padding-top: 24px !important; padding-top: 24px !important;
} }
.contentTitle {
font-size: 1.2rem;
}
.chart {
position: absolute;
width: 100%;
}
.gasPriceDesc {
font-size: 0.9em;
}

View File

@ -62,6 +62,7 @@ export default class Transfer extends Component {
gasEst: '0', gasEst: '0',
gasError: null, gasError: null,
gasPrice: DEFAULT_GASPRICE, gasPrice: DEFAULT_GASPRICE,
gasPriceHistogram: {},
gasPriceError: null, gasPriceError: null,
recipient: '', recipient: '',
recipientError: ERRORS.requireRecipient, recipientError: ERRORS.requireRecipient,
@ -89,7 +90,9 @@ export default class Transfer extends Component {
current={ stage } current={ stage }
steps={ extras ? STAGES_EXTRA : STAGES_BASIC } steps={ extras ? STAGES_EXTRA : STAGES_BASIC }
waiting={ extras ? [2] : [1] } waiting={ extras ? [2] : [1] }
visible> visible
scroll
>
{ this.renderPage() } { this.renderPage() }
</Modal> </Modal>
); );
@ -169,6 +172,10 @@ export default class Transfer extends Component {
} }
renderExtrasPage () { renderExtrasPage () {
if (!this.state.gasPriceHistogram) {
return null;
}
return ( return (
<Extras <Extras
isEth={ this.state.isEth } isEth={ this.state.isEth }
@ -180,6 +187,7 @@ export default class Transfer extends Component {
gasPrice={ this.state.gasPrice } gasPrice={ this.state.gasPrice }
gasPriceDefault={ this.state.gasPriceDefault } gasPriceDefault={ this.state.gasPriceDefault }
gasPriceError={ this.state.gasPriceError } gasPriceError={ this.state.gasPriceError }
gasPriceHistogram={ this.state.gasPriceHistogram }
total={ this.state.total } total={ this.state.total }
totalError={ this.state.totalError } totalError={ this.state.totalError }
onChange={ this.onUpdateDetails } /> onChange={ this.onUpdateDetails } />
@ -581,12 +589,16 @@ export default class Transfer extends Component {
getDefaults = () => { getDefaults = () => {
const { api } = this.context; const { api } = this.context;
api.eth Promise
.gasPrice() .all([
.then((gasPrice) => { api.ethcore.gasPriceHistogram(),
api.eth.gasPrice()
])
.then(([gasPriceHistogram, gasPrice]) => {
this.setState({ this.setState({
gasPrice: gasPrice.toString(), gasPrice: gasPrice.toString(),
gasPriceDefault: gasPrice.toFormat() gasPriceDefault: gasPrice.toFormat(),
gasPriceHistogram
}, this.recalculate); }, this.recalculate);
}) })
.catch((error) => { .catch((error) => {

View File

@ -81,11 +81,18 @@ class Account extends Component {
} }
renderActionbar () { renderActionbar () {
const { address } = this.props.params;
const { balances } = this.props;
const balance = balances[address];
const showTransferButton = !!(balance && balance.tokens);
const buttons = [ const buttons = [
<Button <Button
key='transferFunds' key='transferFunds'
icon={ <ContentSend /> } icon={ <ContentSend /> }
label='transfer' label='transfer'
disabled={ !showTransferButton }
onClick={ this.onTransferClick } />, onClick={ this.onTransferClick } />,
<Button <Button
key='shapeshift' key='shapeshift'