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:
parent
9b7637e012
commit
952cc98df3
@ -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 }
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user