mango-v4-ui/components/trade/DepthChart.tsx

478 lines
15 KiB
TypeScript
Raw Normal View History

2023-07-12 07:37:18 -07:00
import Slider from '@components/forms/Slider'
import useMarkPrice from 'hooks/useMarkPrice'
2023-07-02 22:55:25 -07:00
import useOrderbookSubscription, {
cumOrderbookSide,
} from 'hooks/useOrderbookSubscription'
import useSelectedMarket from 'hooks/useSelectedMarket'
2023-07-13 03:37:41 -07:00
import { useViewport } from 'hooks/useViewport'
2023-07-02 22:55:25 -07:00
import { useTheme } from 'next-themes'
2023-07-12 07:37:18 -07:00
import { useCallback, useMemo, useState } from 'react'
import {
XAxis,
YAxis,
ResponsiveContainer,
AreaChart,
Area,
ReferenceLine,
Label,
2023-07-13 15:53:50 -07:00
LabelProps,
2023-07-12 07:37:18 -07:00
} from 'recharts'
import { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalChart'
2023-07-02 22:55:25 -07:00
import { COLORS } from 'styles/colors'
2023-07-12 20:59:03 -07:00
import { floorToDecimal, getDecimalCount } from 'utils/numbers'
2023-07-13 03:37:41 -07:00
import { gridBreakpoints } from './TradeAdvancedPage'
2023-07-13 15:53:50 -07:00
import { CartesianViewBox } from 'recharts/types/util/types'
2023-07-12 20:59:03 -07:00
type LabelPosition =
| 'left'
| 'right'
| 'center'
| 'bottom'
| 'insideLeft'
| 'insideRight'
| 'insideTop'
| 'insideBottom'
| 'insideTopLeft'
| 'insideTopRight'
| 'insideBottomLeft'
| 'insideBottomRight'
| 'top'
2023-06-29 20:05:41 -07:00
2023-07-13 15:53:50 -07:00
const Y_TICK_COUNT = 10
// content: (props)=> {…}
// fill: "var(--fgd-2)"
// fontSize: 9
// offset: 7
// position: "left"
// value: "0.0"
// viewBox: {x: 52, y: 452, width: 82, height: 0}
interface CustomLabel extends LabelProps {
viewBox?: CartesianViewBox
}
const MarkPriceLabel = ({ value, viewBox }: CustomLabel) => {
if (typeof value === 'string' && viewBox?.x && viewBox?.y) {
const { x, y } = viewBox
const valueLength = value.length
const valueWidth = valueLength * 6
return (
<g>
<foreignObject x={x - valueWidth} y={y - 10} width="100%" height={20}>
<div className="w-max rounded bg-th-bkg-3 p-1 font-mono text-[9px] leading-none">
{value}
</div>
</foreignObject>
</g>
)
} else return null
}
2023-07-02 22:55:25 -07:00
const DepthChart = ({ grouping }: { grouping: number }) => {
const { theme } = useTheme()
const { serumOrPerpMarket } = useSelectedMarket()
2023-07-12 07:37:18 -07:00
const [priceRangePercent, setPriceRangePercentPercent] = useState('5')
const [mouseData, setMouseData] = useState<cumOrderbookSide | null>(null)
const markPrice = useMarkPrice()
const orderbook = useOrderbookSubscription(100, grouping)
2023-07-13 03:37:41 -07:00
const { width } = useViewport()
const increaseHeight = width ? width > gridBreakpoints.xxxl : false
2023-06-29 20:05:41 -07:00
2023-07-13 15:53:50 -07:00
// format chart data for the bids and asks series
2023-07-02 22:55:25 -07:00
const mergeCumulativeData = (
bids: cumOrderbookSide[],
asks: cumOrderbookSide[]
) => {
const bidsWithSide = bids.map((b) => ({ ...b, bids: b.cumulativeSize }))
const asksWithSide = asks.map((a) => ({ ...a, asks: a.cumulativeSize }))
return [...bidsWithSide, ...asksWithSide].sort((a, b) => a.price - b.price)
2023-06-29 20:05:41 -07:00
}
2023-07-02 22:55:25 -07:00
const chartData = useMemo(() => {
if (!orderbook) return []
return mergeCumulativeData(orderbook.bids, orderbook.asks)
}, [orderbook])
2023-07-13 15:53:50 -07:00
// find the max value for the x-axis
2023-07-12 07:37:18 -07:00
const findXDomainMax = (
data: cumOrderbookSide[],
yMin: number,
yMax: number
) => {
let closestItemForYMin = 0
let minDifferenceForYMin = Infinity
let closestItemForYMax = 0
let minDifferenceForYMax = Infinity
for (const item of data) {
const differenceForYMin = Math.abs(item.price - yMin)
const differenceForYMax = Math.abs(item.price - yMax)
if (differenceForYMin < minDifferenceForYMin) {
minDifferenceForYMin = differenceForYMin
closestItemForYMin = item.cumulativeSize
}
if (differenceForYMax < minDifferenceForYMax) {
minDifferenceForYMax = differenceForYMax
closestItemForYMax = item.cumulativeSize
}
}
return Math.max(closestItemForYMin, closestItemForYMax)
}
2023-07-13 15:53:50 -07:00
// calc axis domains
2023-07-12 07:37:18 -07:00
const [xMax, yMin, yMax] = useMemo(() => {
let xMax = 100
let yMin = 0
let yMax = 100
if (markPrice) {
yMin = markPrice / (1 + parseFloat(priceRangePercent) / 100)
yMax = markPrice * (1 + parseFloat(priceRangePercent) / 100)
}
if (chartData.length) {
xMax = findXDomainMax(chartData, yMin, yMax)
}
return [xMax, yMin, yMax]
}, [chartData, markPrice, priceRangePercent])
2023-07-02 22:55:25 -07:00
2023-07-13 15:53:50 -07:00
// get nearest data on the opposing side to the mouse
const opposingMouseReference = useMemo(() => {
if (!markPrice || !mouseData) return null
const mousePrice = mouseData.price
const difference = Math.abs(mousePrice - markPrice) / markPrice
if (mousePrice >= markPrice) {
const price = markPrice / (1 + difference)
let closestItemBelow = null
let minDifference = Infinity
for (const item of chartData) {
const difference = Math.abs(item.price - price)
if (difference < minDifference) {
minDifference = difference
closestItemBelow = item
}
}
return closestItemBelow
} else {
const price = markPrice * (1 + difference)
let closestItemAbove = null
let minDifference = Infinity
for (const item of chartData) {
const difference = Math.abs(item.price - price)
if (difference < minDifference) {
minDifference = difference
closestItemAbove = item
}
}
return closestItemAbove
}
}, [markPrice, mouseData])
const priceFormatter = useCallback(
(price: number) => {
if (!serumOrPerpMarket) return price.toFixed(1)
2023-07-12 07:37:18 -07:00
const tickDecimals = getDecimalCount(serumOrPerpMarket.tickSize)
if (tickDecimals >= 7) {
2023-07-13 15:53:50 -07:00
return price.toExponential(3)
} else return price.toFixed(tickDecimals)
2023-07-12 07:37:18 -07:00
},
[serumOrPerpMarket]
)
2023-07-02 22:55:25 -07:00
2023-07-13 15:53:50 -07:00
const isWithinRangeOfTick = useCallback(
(value: number, baseValue: number) => {
const difference = Math.abs(value - baseValue)
const range = (yMax - yMin) / Y_TICK_COUNT
return difference <= range
},
[yMin, yMax]
)
const yTickFormatter = useCallback(
(tick: number) => {
if ((markPrice && isWithinRangeOfTick(markPrice, tick)) || mouseData) {
return ''
}
return priceFormatter(tick)
},
[markPrice, mouseData]
)
2023-07-12 20:59:03 -07:00
const getChartReferenceColor = (price: number | undefined) => {
if (!price || !markPrice) return 'var(--fgd-2)'
return price > markPrice ? 'var(--down)' : 'var(--up)'
}
const getPercentFromMarkPrice = (price: number | undefined) => {
if (!price || !markPrice) return
const percentDif = ((price - markPrice) / markPrice) * 100
return `${percentDif.toFixed(2)}%`
}
const getSizeFromMouseData = useCallback(
(size: number | undefined) => {
if (!size || !serumOrPerpMarket) return
return floorToDecimal(
size,
getDecimalCount(serumOrPerpMarket.tickSize)
).toString()
},
[serumOrPerpMarket]
)
const getSizeLabelPosition = useCallback(
(size: number | undefined, price: number | undefined) => {
if (!xMax || !size || !price || !markPrice) return `insideRight`
const yPosition = price > markPrice ? 'Top' : 'Bottom'
const midPoint = xMax / 2
const xPosition = size > midPoint ? 'Left' : 'Right'
return `inside${yPosition}${xPosition}` as LabelPosition
},
[xMax, markPrice]
)
const getPercentLabelPosition = useCallback(
(price: number | undefined) => {
if (!markPrice || !price || !yMax || !yMin) return 'bottom'
const upperMidPoint = (markPrice + yMax) / 2
const lowerMidPoint = (markPrice + yMin) / 2
return price > markPrice
? price > upperMidPoint
? 'bottom'
: 'top'
: price > lowerMidPoint
? 'bottom'
: 'top'
},
[markPrice, yMax, yMin]
)
2023-07-12 07:37:18 -07:00
const handleMouseMove: CategoricalChartFunc = (coords) => {
if (coords?.activePayload) {
setMouseData(coords.activePayload[0].payload)
}
}
2023-07-02 22:55:25 -07:00
2023-07-12 07:37:18 -07:00
const handleMouseLeave = () => {
setMouseData(null)
}
2023-06-29 20:05:41 -07:00
return (
2023-07-12 07:37:18 -07:00
<>
<div className="flex h-10 items-center border-b border-th-bkg-3 py-1 px-2">
<div className="flex items-center">
<span className="w-16 font-mono text-xs text-th-fgd-3">
{priceRangePercent}%
</span>
<Slider
amount={parseFloat(priceRangePercent)}
max="50"
min="0.5"
onChange={(p) => setPriceRangePercentPercent(p)}
step={0.5}
2023-07-02 22:55:25 -07:00
/>
2023-07-12 07:37:18 -07:00
</div>
</div>
2023-07-13 03:37:41 -07:00
<div className={increaseHeight ? 'h-[570px]' : 'h-[482px]'}>
2023-07-12 07:37:18 -07:00
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
layout="vertical"
margin={{
2023-07-12 20:59:03 -07:00
top: 8,
2023-07-13 15:53:50 -07:00
left: -8,
2023-07-02 22:55:25 -07:00
}}
2023-07-12 07:37:18 -07:00
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
<XAxis
axisLine={false}
reversed={true}
domain={[() => 0, () => xMax]}
type="number"
tick={false}
tickLine={false}
/>
<YAxis
dataKey="price"
reversed={true}
domain={[() => yMin, () => yMax]}
axisLine={false}
tick={{
2023-07-12 20:59:03 -07:00
fill: 'var(--fgd-4)',
2023-07-12 07:37:18 -07:00
fontSize: 8,
}}
2023-07-13 15:53:50 -07:00
tickCount={Y_TICK_COUNT}
2023-07-12 07:37:18 -07:00
tickLine={false}
2023-07-13 15:53:50 -07:00
tickFormatter={(tick) => yTickFormatter(tick)}
2023-07-12 20:59:03 -07:00
/>
<Area
type="step"
dataKey="bids"
stroke={COLORS.UP[theme]}
fill="url(#bidsGradient)"
/>
<Area
type="step"
dataKey="asks"
stroke={COLORS.DOWN[theme]}
fill="url(#asksGradient)"
2023-07-12 07:37:18 -07:00
/>
2023-07-12 17:32:22 -07:00
<ReferenceLine
y={mouseData?.price}
2023-07-12 20:59:03 -07:00
stroke={getChartReferenceColor(mouseData?.price)}
2023-07-12 17:32:22 -07:00
strokeDasharray="3, 3"
2023-07-12 20:59:03 -07:00
>
<Label
2023-07-13 15:53:50 -07:00
value={mouseData ? priceFormatter(mouseData.price) : ''}
fontSize={9}
2023-07-12 20:59:03 -07:00
fill={getChartReferenceColor(mouseData?.price)}
position="left"
2023-07-13 15:53:50 -07:00
offset={5}
2023-07-12 20:59:03 -07:00
/>
<Label
value={getPercentFromMarkPrice(mouseData?.price)}
2023-07-13 15:53:50 -07:00
fontSize={9}
2023-07-12 20:59:03 -07:00
fill={getChartReferenceColor(opposingMouseReference?.price)}
position={getPercentLabelPosition(mouseData?.price)}
2023-07-13 15:53:50 -07:00
offset={6}
2023-07-12 20:59:03 -07:00
/>
</ReferenceLine>
2023-07-12 07:37:18 -07:00
<ReferenceLine
y={opposingMouseReference?.price}
2023-07-12 20:59:03 -07:00
stroke={getChartReferenceColor(opposingMouseReference?.price)}
2023-07-12 17:32:22 -07:00
strokeDasharray="3, 3"
2023-07-12 20:59:03 -07:00
>
<Label
2023-07-13 15:53:50 -07:00
value={
opposingMouseReference
? priceFormatter(opposingMouseReference.price)
: ''
}
fontSize={9}
2023-07-12 20:59:03 -07:00
fill={getChartReferenceColor(opposingMouseReference?.price)}
position="left"
2023-07-13 15:53:50 -07:00
offset={5}
2023-07-12 20:59:03 -07:00
/>
<Label
value={getPercentFromMarkPrice(opposingMouseReference?.price)}
2023-07-13 15:53:50 -07:00
fontSize={9}
2023-07-12 20:59:03 -07:00
fill={getChartReferenceColor(mouseData?.price)}
position={getPercentLabelPosition(
opposingMouseReference?.price
)}
2023-07-13 15:53:50 -07:00
offset={6}
2023-07-12 20:59:03 -07:00
/>
</ReferenceLine>
2023-07-12 17:32:22 -07:00
<ReferenceLine
2023-07-12 20:59:03 -07:00
stroke={getChartReferenceColor(mouseData?.price)}
strokeDasharray="3, 3"
segment={
mouseData && mouseData?.price >= markPrice
? [
{ x: mouseData?.cumulativeSize, y: markPrice },
{ x: mouseData?.cumulativeSize, y: yMax },
]
: [
{ x: mouseData?.cumulativeSize, y: yMin },
{ x: mouseData?.cumulativeSize, y: markPrice },
]
}
>
<Label
value={getSizeFromMouseData(mouseData?.cumulativeSize)}
2023-07-13 15:53:50 -07:00
fontSize={9}
fill={getChartReferenceColor(mouseData?.price)}
2023-07-12 20:59:03 -07:00
position={getSizeLabelPosition(
mouseData?.cumulativeSize,
mouseData?.price
)}
2023-07-13 15:53:50 -07:00
offset={6}
2023-07-12 20:59:03 -07:00
/>
</ReferenceLine>
<ReferenceLine
stroke={getChartReferenceColor(opposingMouseReference?.price)}
2023-07-12 17:32:22 -07:00
strokeDasharray="3, 3"
segment={
opposingMouseReference &&
opposingMouseReference?.price >= markPrice
? [
{
x: opposingMouseReference?.cumulativeSize,
y: markPrice,
},
{ x: opposingMouseReference?.cumulativeSize, y: yMax },
]
: [
{ x: opposingMouseReference?.cumulativeSize, y: yMin },
{
x: opposingMouseReference?.cumulativeSize,
y: markPrice,
},
]
}
2023-07-12 20:59:03 -07:00
>
<Label
value={getSizeFromMouseData(
opposingMouseReference?.cumulativeSize
)}
2023-07-13 15:53:50 -07:00
fontSize={9}
fill={getChartReferenceColor(opposingMouseReference?.price)}
2023-07-12 20:59:03 -07:00
position={getSizeLabelPosition(
opposingMouseReference?.cumulativeSize,
opposingMouseReference?.price
)}
2023-07-13 15:53:50 -07:00
offset={6}
2023-07-12 20:59:03 -07:00
/>
</ReferenceLine>
2023-07-13 15:53:50 -07:00
{markPrice ? (
<ReferenceLine y={markPrice} stroke="var(--bkg-4)">
<Label
value={priceFormatter(markPrice)}
content={<MarkPriceLabel />}
/>
</ReferenceLine>
) : null}
2023-07-12 07:37:18 -07:00
<defs>
2023-07-13 15:53:50 -07:00
<linearGradient id="bidsGradient" x1="0" y1="0" x2="1" y2="0">
2023-07-12 07:37:18 -07:00
<stop
offset="0%"
stopColor={COLORS.UP[theme]}
stopOpacity={0.15}
/>
<stop
offset="99%"
stopColor={COLORS.UP[theme]}
stopOpacity={0}
/>
</linearGradient>
2023-07-13 15:53:50 -07:00
<linearGradient id="asksGradient" x1="0" y1="0" x2="1" y2="0">
2023-07-12 07:37:18 -07:00
<stop
offset="0%"
stopColor={COLORS.DOWN[theme]}
stopOpacity={0.15}
/>
<stop
offset="99%"
stopColor={COLORS.DOWN[theme]}
stopOpacity={0}
/>
</linearGradient>
</defs>
</AreaChart>
</ResponsiveContainer>
</div>
</>
2023-06-29 20:05:41 -07:00
)
}
export default DepthChart