fix: add support for full range positions in add liquidity (#2090)

* remove arbitrary range buttons and move full range button

* better align full range to deposit

* support dragging range when in full range

* hack to support full range brushing

* restore zoom levels

* adjusted for mocks

* fix styling

* simplify type

* restore rate toggle change

* fix lower bound by looking at isSorted

* better align bottoms

* add reset button for full range positions

* only flip when not at limit

* fix +/- buttons in range selector

* add help link
This commit is contained in:
Justin Domingue 2021-07-29 13:47:03 -07:00 committed by GitHub
parent 9b7637e012
commit 952cc98df3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 91 additions and 130 deletions

@ -43,7 +43,8 @@ const FLIP_HANDLE_THRESHOLD_PX = 20
// margin to prevent tick snapping from putting the brush off screen
const BRUSH_EXTENT_MARGIN_PX = 2
const compare = (a1: [number, number], a2: [number, number]): boolean => a1[0] !== a2[0] || a1[1] !== a2[1]
const compare = (a1: [number, number], a2: [number, number], xScale: ScaleLinear<number, number>): boolean =>
xScale(a1[0]) !== xScale(a2[0]) || xScale(a1[1]) !== xScale(a2[1])
export const Brush = ({
id,
@ -62,7 +63,7 @@ export const Brush = ({
interactive: boolean
brushLabelValue: (d: 'w' | 'e', x: number) => string
brushExtent: [number, number]
setBrushExtent: (extent: [number, number]) => void
setBrushExtent: (extent: [number, number], mode: string | undefined) => void
innerWidth: number
innerHeight: number
westHandleColor: string
@ -79,7 +80,9 @@ export const Brush = ({
const previousBrushExtent = usePrevious(brushExtent)
const brushed = useCallback(
({ type, selection }: D3BrushEvent<unknown>) => {
(event: D3BrushEvent<unknown>) => {
const { type, selection, mode } = event
if (!selection) {
setLocalBrushExtent(null)
return
@ -88,8 +91,8 @@ export const Brush = ({
const scaled = (selection as [number, number]).map(xScale.invert) as [number, number]
// avoid infinite render loop by checking for change
if (type === 'end' && compare(brushExtent, scaled)) {
setBrushExtent(scaled)
if (type === 'end' && compare(brushExtent, scaled, xScale)) {
setBrushExtent(scaled, mode)
}
setLocalBrushExtent(scaled)
@ -118,7 +121,7 @@ export const Brush = ({
brushBehavior.current(select(brushRef.current))
if (previousBrushExtent && compare(brushExtent, previousBrushExtent)) {
if (previousBrushExtent && compare(brushExtent, previousBrushExtent, xScale)) {
select(brushRef.current)
.transition()
.call(brushBehavior.current.move as any, brushExtent.map(xScale))

@ -1,5 +1,6 @@
import { max, scaleLinear, ZoomTransform } from 'd3'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Bound } from 'state/mint/v3/actions'
import { Area } from './Area'
import { AxisBottom } from './AxisBottom'
import { Brush } from './Brush'
@ -13,6 +14,7 @@ export const yAccessor = (d: ChartEntry) => d.activeLiquidity
export function Chart({
id = 'liquidityChartRangeInput',
data: { series, current },
ticksAtLimit,
styles,
dimensions: { width, height },
margins,
@ -56,7 +58,7 @@ export function Chart({
useEffect(() => {
if (!brushDomain) {
onBrushDomainChange(xScale.domain() as [number, number])
onBrushDomainChange(xScale.domain() as [number, number], undefined)
}
}, [brushDomain, onBrushDomainChange, xScale])
@ -71,7 +73,13 @@ export function Chart({
// allow zooming inside the x-axis
height
}
showClear={false}
resetBrush={() => {
onBrushDomainChange(
[current * zoomLevels.initialMin, current * zoomLevels.initialMax] as [number, number],
'reset'
)
}}
showResetButton={Boolean(ticksAtLimit[Bound.LOWER] || ticksAtLimit[Bound.UPPER])}
zoomLevels={zoomLevels}
/>
<svg width="100%" height="100%" viewBox={`0 0 ${width} ${height}`} style={{ overflow: 'visible' }}>

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useRef } from 'react'
import { ButtonGray } from 'components/Button'
import styled from 'styled-components/macro'
import { ScaleLinear, select, ZoomBehavior, zoom, ZoomTransform } from 'd3'
import { ScaleLinear, select, ZoomBehavior, zoom, ZoomTransform, zoomIdentity } from 'd3'
import { RefreshCcw, ZoomIn, ZoomOut } from 'react-feather'
import { ZoomLevels } from './types'
@ -41,7 +41,8 @@ export default function Zoom({
setZoom,
width,
height,
showClear,
resetBrush,
showResetButton,
zoomLevels,
}: {
svg: SVGElement | null
@ -49,12 +50,13 @@ export default function Zoom({
setZoom: (transform: ZoomTransform) => void
width: number
height: number
showClear: boolean
resetBrush: () => void
showResetButton: boolean
zoomLevels: ZoomLevels
}) {
const zoomBehavior = useRef<ZoomBehavior<Element, unknown>>()
const [zoomIn, zoomOut, reset, initial] = useMemo(
const [zoomIn, zoomOut, zoomInitial, zoomReset] = useMemo(
() => [
() =>
svg &&
@ -73,15 +75,16 @@ export default function Zoom({
zoomBehavior.current &&
select(svg as Element)
.transition()
.call(zoomBehavior.current.scaleTo, 1),
.call(zoomBehavior.current.scaleTo, 0.5),
() =>
svg &&
zoomBehavior.current &&
select(svg as Element)
.call(zoomBehavior.current.transform, zoomIdentity.translate(0, 0).scale(1))
.transition()
.call(zoomBehavior.current.scaleTo, 0.5),
],
[svg, zoomBehavior]
[svg]
)
useEffect(() => {
@ -100,13 +103,19 @@ export default function Zoom({
useEffect(() => {
// reset zoom to initial on zoomLevel change
initial()
}, [initial, zoomLevels])
zoomInitial()
}, [zoomInitial, zoomLevels])
return (
<Wrapper count={showClear ? 3 : 2}>
{showClear && (
<Button onClick={reset} disabled={false}>
<Wrapper count={showResetButton ? 3 : 2}>
{showResetButton && (
<Button
onClick={() => {
resetBrush()
zoomReset()
}}
disabled={false}
>
<RefreshCcw size={16} />
</Button>
)}

@ -87,6 +87,8 @@ export default function LiquidityChartRangeInput({
const tokenAColor = useColor(currencyA?.wrapped)
const tokenBColor = useColor(currencyB?.wrapped)
const isSorted = currencyA && currencyB && currencyA?.wrapped.sortsBefore(currencyB?.wrapped)
const { isLoading, isUninitialized, isError, error, formattedData } = useDensityChartData({
currencyA,
currencyB,
@ -94,7 +96,7 @@ export default function LiquidityChartRangeInput({
})
const onBrushDomainChangeEnded = useCallback(
(domain) => {
(domain, mode) => {
let leftRangeValue = Number(domain[0])
const rightRangeValue = Number(domain[1])
@ -104,40 +106,48 @@ export default function LiquidityChartRangeInput({
batch(() => {
// simulate user input for auto-formatting and other validations
leftRangeValue > 0 && onLeftRangeInput(leftRangeValue.toFixed(6))
rightRangeValue > 0 && onRightRangeInput(rightRangeValue.toFixed(6))
if (
(!ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER] || mode === 'handle' || mode === 'reset') &&
leftRangeValue > 0
) {
onLeftRangeInput(leftRangeValue.toFixed(6))
}
if ((!ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER] || mode === 'reset') && rightRangeValue > 0) {
// todo: remove this check. Upper bound for large numbers
// sometimes fails to parse to tick.
if (rightRangeValue < 1e35) {
onRightRangeInput(rightRangeValue.toFixed(6))
}
}
})
},
[onLeftRangeInput, onRightRangeInput]
[isSorted, onLeftRangeInput, onRightRangeInput, ticksAtLimit]
)
interactive = interactive && Boolean(formattedData?.length)
const brushDomain: [number, number] | undefined = useMemo(() => {
const isSorted = currencyA && currencyB && currencyA?.wrapped.sortsBefore(currencyB?.wrapped)
const leftPrice = isSorted ? priceLower : priceUpper?.invert()
const rightPrice = isSorted ? priceUpper : priceLower?.invert()
return leftPrice && rightPrice
? [parseFloat(leftPrice?.toSignificant(5)), parseFloat(rightPrice?.toSignificant(5))]
? [parseFloat(leftPrice?.toSignificant(6)), parseFloat(rightPrice?.toSignificant(6))]
: undefined
}, [currencyA, currencyB, priceLower, priceUpper])
}, [isSorted, priceLower, priceUpper])
const brushLabelValue = useCallback(
(d: 'w' | 'e', x: number) => {
if (!price) return ''
if (d === 'w' && ticksAtLimit[Bound.LOWER]) return '0'
if (d === 'e' && ticksAtLimit[Bound.UPPER]) return '∞'
//const percent = (((x < price ? -1 : 1) * (Math.max(x, price) - Math.min(x, price))) / Math.min(x, price)) * 100
if (d === 'w' && ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER]) return '0'
if (d === 'e' && ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER]) return '∞'
const percent = (x < price ? -1 : 1) * ((Math.max(x, price) - Math.min(x, price)) / price) * 100
return price ? `${format(Math.abs(percent) > 1 ? '.2~s' : '.2~f')(percent)}%` : ''
},
[price, ticksAtLimit]
[isSorted, price, ticksAtLimit]
)
if (isError) {
@ -189,6 +199,7 @@ export default function LiquidityChartRangeInput({
brushDomain={brushDomain}
onBrushDomainChange={onBrushDomainChangeEnded}
zoomLevels={ZOOM_LEVELS[feeAmount ?? FeeAmount.MEDIUM]}
ticksAtLimit={ticksAtLimit}
/>
</ChartWrapper>
)}

@ -1,3 +1,5 @@
import { Bound } from 'state/mint/v3/actions'
export interface ChartEntry {
activeLiquidity: number
price0: number
@ -30,6 +32,7 @@ export interface LiquidityChartRangeInputProps {
series: ChartEntry[]
current: number
}
ticksAtLimit: { [bound in Bound]?: boolean | undefined }
styles: {
area: {
@ -52,7 +55,7 @@ export interface LiquidityChartRangeInputProps {
brushLabels: (d: 'w' | 'e', x: number) => string
brushDomain: [number, number] | undefined
onBrushDomainChange: (domain: [number, number]) => void
onBrushDomainChange: (domain: [number, number], mode: string | undefined) => void
zoomLevels: ZoomLevels
}

@ -4,76 +4,18 @@ import { AutoRow } from 'components/Row'
import { TYPE } from 'theme'
import styled from 'styled-components/macro'
import { Trans } from '@lingui/macro'
import { FeeAmount } from '@uniswap/v3-sdk'
import ReactGA from 'react-ga'
const Button = styled(ButtonOutlined).attrs(() => ({
padding: '4px',
borderRadius: '8px',
padding: '8px',
$borderRadius: '8px',
}))`
color: ${({ theme }) => theme.text1};
flex: 1;
background-color: ${({ theme }) => theme.bg2};
`
const RANGES = {
[FeeAmount.LOW]: [
{ label: '0.05', ticks: 5 },
{ label: '0.1', ticks: 10 },
{ label: '0.2', ticks: 20 },
],
[FeeAmount.MEDIUM]: [
{ label: '1', ticks: 100 },
{ label: '10', ticks: 953 },
{ label: '50', ticks: 4055 },
],
[FeeAmount.HIGH]: [
{ label: '2', ticks: 198 },
{ label: '10', ticks: 953 },
{ label: '80', ticks: 5878 },
],
}
interface PresetsButtonProps {
feeAmount: FeeAmount | undefined
setRange: (numTicks: number) => void
setFullRange: () => void
}
const PresetButton = ({
values: { label, ticks },
setRange,
}: {
values: {
label: string
ticks: number
}
setRange: (numTicks: number) => void
}) => (
<Button
onClick={() => {
setRange(ticks)
ReactGA.event({
category: 'Liquidity',
action: 'Preset clicked',
label: label,
})
}}
>
<TYPE.body fontSize={12}>
<Trans>+/- {label}%</Trans>
</TYPE.body>
</Button>
)
export default function PresetsButtons({ feeAmount, setRange, setFullRange }: PresetsButtonProps) {
feeAmount = feeAmount ?? FeeAmount.LOW
export default function PresetsButtons({ setFullRange }: { setFullRange: () => void }) {
return (
<AutoRow gap="4px" width="auto">
<PresetButton values={RANGES[feeAmount][0]} setRange={setRange} />
<PresetButton values={RANGES[feeAmount][1]} setRange={setRange} />
<PresetButton values={RANGES[feeAmount][2]} setRange={setRange} />
<Button onClick={() => setFullRange()}>
<TYPE.body fontSize={12}>
<Trans>Full Range</Trans>

@ -44,13 +44,13 @@ export default function RangeSelector({
<AutoColumn gap="md">
<RowBetween>
<StepCounter
value={ticksAtLimit[Bound.LOWER] ? '0' : leftPrice?.toSignificant(5) ?? ''}
value={ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER] ? '0' : leftPrice?.toSignificant(5) ?? ''}
onUserInput={onLeftRangeInput}
width="48%"
decrement={isSorted ? getDecrementLower : getIncrementUpper}
increment={isSorted ? getIncrementLower : getDecrementUpper}
decrementDisabled={ticksAtLimit[Bound.LOWER]}
incrementDisabled={ticksAtLimit[Bound.LOWER]}
decrementDisabled={ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER]}
incrementDisabled={ticksAtLimit[isSorted ? Bound.LOWER : Bound.UPPER]}
feeAmount={feeAmount}
label={leftPrice ? `${currencyB?.symbol}` : '-'}
title={<Trans>Min Price</Trans>}
@ -58,13 +58,13 @@ export default function RangeSelector({
tokenB={currencyB?.symbol}
/>
<StepCounter
value={ticksAtLimit[Bound.UPPER] ? '∞' : rightPrice?.toSignificant(5) ?? ''}
value={ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER] ? '∞' : rightPrice?.toSignificant(5) ?? ''}
onUserInput={onRightRangeInput}
width="48%"
decrement={isSorted ? getDecrementUpper : getIncrementLower}
increment={isSorted ? getIncrementUpper : getDecrementLower}
incrementDisabled={ticksAtLimit[Bound.UPPER]}
decrementDisabled={ticksAtLimit[Bound.UPPER]}
incrementDisabled={ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER]}
decrementDisabled={ticksAtLimit[isSorted ? Bound.UPPER : Bound.LOWER]}
feeAmount={feeAmount}
label={rightPrice ? `${currencyB?.symbol}` : '-'}
tokenA={currencyA?.symbol}

@ -63,6 +63,7 @@ import { useDerivedPositionInfo } from 'hooks/useDerivedPositionInfo'
import { PositionPreview } from 'components/PositionPreview'
import FeeSelector from 'components/FeeSelector'
import RangeSelector from 'components/RangeSelector'
import PresetsButtons from 'components/RangeSelector/PresetsButtons'
import RateToggle from 'components/RateToggle'
import { BigNumber } from '@ethersproject/bignumber'
import { AddRemoveTabs } from 'components/NavigationTabs'
@ -604,9 +605,11 @@ export default function AddLiquidity({
currencyA={baseCurrency}
currencyB={quoteCurrency}
handleRateToggle={() => {
onLeftRangeInput((invertPrice ? priceLower : priceUpper?.invert())?.toSignificant(6) ?? '')
onRightRangeInput((invertPrice ? priceUpper : priceLower?.invert())?.toSignificant(6) ?? '')
onFieldAInput(formattedAmounts[Field.CURRENCY_B] ?? '')
if (!ticksAtLimit[Bound.LOWER] && !ticksAtLimit[Bound.UPPER]) {
onLeftRangeInput((invertPrice ? priceLower : priceUpper?.invert())?.toSignificant(6) ?? '')
onRightRangeInput((invertPrice ? priceUpper : priceLower?.invert())?.toSignificant(6) ?? '')
onFieldAInput(formattedAmounts[Field.CURRENCY_B] ?? '')
}
history.push(
`/add/${currencyIdB as string}/${currencyIdA as string}${feeAmount ? '/' + feeAmount : ''}`
)
@ -863,6 +866,13 @@ export default function AddLiquidity({
feeAmount={feeAmount}
ticksAtLimit={ticksAtLimit}
/>
{!noLiquidity && (
<PresetsButtons
setFullRange={() => {
setShowCapitalEfficiencyWarning(true)
}}
/>
)}
</AutoColumn>
</StackedItem>
@ -890,7 +900,7 @@ export default function AddLiquidity({
Full range positions may earn less fees than concentrated positions. Learn more{' '}
<ExternalLink
style={{ color: theme.yellow3, textDecoration: 'underline' }}
href={''}
href={'https://help.uniswap.org/en/articles/5406286-v3-faq-liquidity-providing'}
>
here
</ExternalLink>

@ -542,34 +542,9 @@ export function useRangeHopCallbacks(
return ''
}, [baseToken, quoteToken, tickUpper, feeAmount, pool])
const getSetRange = useCallback(
(numTicks: number) => {
if (baseToken && quoteToken && feeAmount && pool) {
// calculate range around current price given `numTicks`
const newPriceLower = tickToPrice(
baseToken,
quoteToken,
Math.max(TickMath.MIN_TICK, pool.tickCurrent - numTicks)
)
const newPriceUpper = tickToPrice(
baseToken,
quoteToken,
Math.min(TickMath.MAX_TICK, pool.tickCurrent + numTicks)
)
return [
newPriceLower.toSignificant(5, undefined, Rounding.ROUND_UP),
newPriceUpper.toSignificant(5, undefined, Rounding.ROUND_UP),
]
}
return ['', '']
},
[baseToken, quoteToken, feeAmount, pool]
)
const getSetFullRange = useCallback(() => {
dispatch(setFullRange())
}, [dispatch])
return { getDecrementLower, getIncrementLower, getDecrementUpper, getIncrementUpper, getSetRange, getSetFullRange }
return { getDecrementLower, getIncrementLower, getDecrementUpper, getIncrementUpper, getSetFullRange }
}