Skip to content
This repository has been archived by the owner on Jun 24, 2022. It is now read-only.

Commit

Permalink
feat(pools): add liquidity chart range input primitives (#1990)
Browse files Browse the repository at this point in the history
* first iteration of liquidity chart primitives

* add tickprocessed type

* clean up
  • Loading branch information
Justin Domingue authored Jul 7, 2021
1 parent e0c6256 commit aea3c1f
Show file tree
Hide file tree
Showing 12 changed files with 1,428 additions and 10 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@
"@react-hook/window-scroll": "^1.3.0",
"@reduxjs/toolkit": "^1.6.0",
"@typechain/ethers-v5": "^7.0.0",
"@types/d3": "^6.7.1",
"@types/jest": "^25.2.1",
"@types/lingui__core": "^2.7.1",
"@types/lingui__macro": "^2.7.4",
"@types/lingui__react": "^2.8.3",
"@types/lodash.clonedeep": "^4.5.6",
"@types/lodash.flatmap": "^4.5.6",
"@types/luxon": "^1.24.4",
"@types/ms.macro": "^2.0.0",
Expand Down Expand Up @@ -66,6 +66,7 @@
"copy-to-clipboard": "^3.2.0",
"cross-env": "^7.0.2",
"cypress": "^4.11.0",
"d3": "^7.0.0",
"eslint": "^7.11.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
Expand All @@ -76,7 +77,6 @@
"graphql-request": "^3.4.0",
"inter-ui": "^3.13.1",
"lightweight-charts": "^3.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.flatmap": "^4.5.0",
"luxon": "^1.25.0",
"ms.macro": "^2.0.0",
Expand Down
44 changes: 44 additions & 0 deletions src/components/LiquidityChartRangeInput/Area.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React, { useMemo } from 'react'
import { area, curveStepAfter, ScaleLinear } from 'd3'
import styled from 'styled-components/macro'
import { ChartEntry } from './types'
import inRange from 'lodash/inRange'

const Path = styled.path<{ fill: string | undefined }>`
opacity: 0.5;
stroke: ${({ fill, theme }) => fill ?? theme.blue2};
fill: ${({ fill, theme }) => fill ?? theme.blue2};
`

export const Area = ({
series,
xScale,
yScale,
xValue,
yValue,
fill,
}: {
series: ChartEntry[]
xScale: ScaleLinear<number, number>
yScale: ScaleLinear<number, number>
xValue: (d: ChartEntry) => number
yValue: (d: ChartEntry) => number
fill?: string | undefined
}) =>
useMemo(
() => (
<Path
fill={fill}
d={
area()
.curve(curveStepAfter)
.x((d: unknown) => xScale(xValue(d as ChartEntry)))
.y1((d: unknown) => yScale(yValue(d as ChartEntry)))
.y0(yScale(0))(
series.filter((d) => inRange(xScale(xValue(d)), 0, innerWidth)) as Iterable<[number, number]>
) ?? undefined
}
/>
),
[fill, series, xScale, xValue, yScale, yValue]
)
43 changes: 43 additions & 0 deletions src/components/LiquidityChartRangeInput/AxisBottom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, { useMemo } from 'react'
import { Axis as d3Axis, axisBottom, NumberValue, ScaleLinear, select } from 'd3'
import styled from 'styled-components/macro'

const StyledGroup = styled.g`
line {
display: none;
}
text {
color: ${({ theme }) => theme.text2};
transform: translateY(5px);
}
`

const Axis = ({ axisGenerator }: { axisGenerator: d3Axis<NumberValue> }) => {
const axisRef = (axis: SVGGElement) => {
axis &&
select(axis)
.call(axisGenerator)
.call((g) => g.select('.domain').remove())
}

return <g ref={axisRef} />
}

export const AxisBottom = ({
xScale,
innerHeight,
offset = 5,
}: {
xScale: ScaleLinear<number, number>
innerHeight: number
offset?: number
}) =>
useMemo(
() => (
<StyledGroup transform={`translate(0, ${innerHeight + offset})`}>
<Axis axisGenerator={axisBottom(xScale).ticks(6)} />
</StyledGroup>
),
[innerHeight, offset, xScale]
)
242 changes: 242 additions & 0 deletions src/components/LiquidityChartRangeInput/Brush.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BrushBehavior, brushX, D3BrushEvent, ScaleLinear, select } from 'd3'
import styled from 'styled-components/macro'
import { brushHandleAccentPath, brushHandlePath } from 'components/LiquidityChartRangeInput/svg'
import usePrevious from 'hooks/usePrevious'

const Handle = styled.path<{ color: string }>`
cursor: ew-resize;
pointer-events: none;
stroke-width: 4;
stroke: ${({ color }) => color};
fill: ${({ color }) => color};
`

const HandleAccent = styled.path`
cursor: ew-resize;
pointer-events: none;
stroke-width: 1.5;
stroke: ${({ theme }) => theme.white};
opacity: 0.6;
`

const LabelGroup = styled.g<{ visible: boolean }>`
opacity: ${({ visible }) => (visible ? '1' : '0')};
transition: opacity 300ms;
`

const TooltipBackground = styled.rect`
fill: ${({ theme }) => theme.bg2};
`

const Tooltip = styled.text`
text-anchor: middle;
font-size: 13px;
fill: ${({ theme }) => theme.text1};
`

export const Brush = ({
id,
xScale,
interactive,
brushLabelValue,
brushExtent,
setBrushExtent,
innerWidth,
innerHeight,
colors,
}: {
id: string
xScale: ScaleLinear<number, number>
interactive: boolean
brushLabelValue: (d: 'w' | 'e', x: number) => string
brushExtent: [number, number]
setBrushExtent: (extent: [number, number]) => void
innerWidth: number
innerHeight: number
colors: {
west: string
east: string
}
}) => {
const brushRef = useRef<SVGGElement | null>(null)
const brushBehavior = useRef<BrushBehavior<SVGGElement> | null>(null)

// only used to drag the handles on brush for performance
const [localBrushExtent, setLocalBrushExtent] = useState<[number, number] | null>(brushExtent)
const [showLabels, setShowLabels] = useState(false)
const [hovering, setHovering] = useState(false)

const previousBrushExtent = usePrevious(brushExtent)

const brushed = useCallback(
({ mode, type, selection }: D3BrushEvent<unknown>) => {
if (!selection) {
setLocalBrushExtent(null)
return
}

const scaled = (selection as [number, number]).map(xScale.invert) as [number, number]

// undefined `mode` means brush was programatically moved
// skip calling the handler to avoid a loop
if (type === 'end' && mode !== undefined) {
setBrushExtent(scaled)
}

setLocalBrushExtent(scaled)
},
[xScale.invert, setBrushExtent]
)

// keep local and external brush extent in sync
// i.e. snap to ticks on bruhs end
useEffect(() => {
setLocalBrushExtent(brushExtent)
}, [brushExtent])

// initialize the brush
useEffect(() => {
if (!brushRef.current) return

brushBehavior.current = brushX<SVGGElement>()
.extent([
[Math.max(0, xScale(0)), 0],
[innerWidth, innerHeight],
])
.handleSize(30)
.filter(() => interactive)
.on('brush end', brushed)

brushBehavior.current(select(brushRef.current))

if (
previousBrushExtent &&
(brushExtent[0] !== previousBrushExtent[0] || brushExtent[1] !== previousBrushExtent[1])
) {
select(brushRef.current)
.transition()
.call(brushBehavior.current.move as any, brushExtent.map(xScale))
}

// brush linear gradient
select(brushRef.current)
.selectAll('.selection')
.attr('stroke', 'none')
.attr('fill-opacity', '0.1')
.attr('fill', `url(#${id}-gradient-selection)`)
}, [brushExtent, brushed, id, innerHeight, innerWidth, interactive, previousBrushExtent, xScale])

// respond to xScale changes only
useEffect(() => {
if (!brushRef.current || !brushBehavior.current) return

brushBehavior.current.move(select(brushRef.current) as any, brushExtent.map(xScale) as any)
// dependency on brushExtent would start an update loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [xScale])

useEffect(() => {
setShowLabels(true)
const timeout = setTimeout(() => setShowLabels(false), 1500)
return () => clearTimeout(timeout)
}, [localBrushExtent])

const flipWestHandle = localBrushExtent && xScale(localBrushExtent[0]) > 15
const flipEastHandle = localBrushExtent && xScale(localBrushExtent[1]) > innerWidth - 15

return useMemo(
() => (
<>
<defs>
<linearGradient id={`${id}-gradient-selection`} x1="0%" y1="100%" x2="100%" y2="100%">
<stop stopColor={colors.west} />
<stop stopColor={colors.east} offset="1" />
</linearGradient>

{/* clips at exactly the svg area */}
<clipPath id={`${id}-brush-clip`}>
<rect x="0" y="0" width={innerWidth} height="100%" />
</clipPath>

<clipPath id={`${id}-handles-clip`}>
<rect x="0" y="0" width="100%" height="100%" />
</clipPath>
</defs>

{/* will host the d3 brush */}
<g
ref={brushRef}
clipPath={`url(#${id}-brush-clip)`}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
/>

{/* custom brush handles */}
{localBrushExtent && (
<>
{/* west handle */}
<g
transform={`translate(${Math.max(0, xScale(localBrushExtent[0]))}, 0), scale(${
flipWestHandle ? '-1' : '1'
}, 1)`}
>
<g clipPath={`url(#${id}-handles-clip)`}>
<Handle color={colors.west} d={brushHandlePath(innerHeight)} />
<HandleAccent d={brushHandleAccentPath()} />
</g>

<LabelGroup
transform={`translate(50,0), scale(${flipWestHandle ? '1' : '-1'}, 1)`}
visible={showLabels || hovering}
>
<TooltipBackground y="0" x="-30" height="30" width="60" rx="8" />
<Tooltip transform={`scale(-1, 1)`} y="15" dominantBaseline="middle">
{brushLabelValue('w', localBrushExtent[0])}
</Tooltip>
</LabelGroup>
</g>

{/* east handle */}
<g
transform={`translate(${Math.min(xScale(localBrushExtent[1]), innerWidth)}, 0), scale(${
flipEastHandle ? '-1' : '1'
}, 1)`}
>
<g clipPath={`url(#${id}-handles-clip)`}>
<Handle color={colors.east} d={brushHandlePath(innerHeight)} />
<HandleAccent d={brushHandleAccentPath()} />
</g>

<LabelGroup
transform={`translate(50,0), scale(${flipEastHandle ? '-1' : '1'}, 1)`}
visible={showLabels || hovering}
>
<TooltipBackground y="0" x="-30" height="30" width="60" rx="8" />
<Tooltip y="15" dominantBaseline="middle">
{brushLabelValue('e', localBrushExtent[1])}
</Tooltip>
</LabelGroup>
</g>
</>
)}
</>
),
[
brushLabelValue,
colors.east,
colors.west,
flipEastHandle,
flipWestHandle,
hovering,
id,
innerHeight,
innerWidth,
localBrushExtent,
showLabels,
xScale,
]
)
}
Loading

0 comments on commit aea3c1f

Please sign in to comment.