diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx new file mode 100644 index 0000000..bede2f7 --- /dev/null +++ b/src/components/Canvas.tsx @@ -0,0 +1,13 @@ +import { forwardRef } from 'react'; + +type CanvasProps = { + className?: string; + width: number | string; + height: number | string; +}; + +export const Canvas = forwardRef( + ({ className, width, height }, canvasRef) => ( + + ) +); diff --git a/src/constants/abacus.ts b/src/constants/abacus.ts index 6bfeef4..1cdbfcc 100644 --- a/src/constants/abacus.ts +++ b/src/constants/abacus.ts @@ -282,13 +282,22 @@ export const TRADE_TYPES: Record< [AbacusOrderType.takeProfitMarket.rawValue]: TradeTypes.TAKE_PROFIT_MARKET, [AbacusOrderType.liquidated.name]: null, - [AbacusOrderType.liquidation.name]: null, + [AbacusOrderType.liquidated.rawValue]: null, [AbacusOrderType.liquidation.name]: null, [AbacusOrderType.liquidation.rawValue]: null, [AbacusOrderType.trailingStop.name]: null, [AbacusOrderType.trailingStop.rawValue]: null, + + [AbacusOrderType.offsetting.name]: null, + [AbacusOrderType.offsetting.rawValue]: null, + + [AbacusOrderType.deleveraged.name]: null, + [AbacusOrderType.deleveraged.rawValue]: null, + + [AbacusOrderType.finalSettlement.name]: null, + [AbacusOrderType.finalSettlement.rawValue]: null, }; // Custom types involving Abacus @@ -307,3 +316,9 @@ export type NetworkConfig = Partial<{ }>; export type ConnectNetworkEvent = CustomEvent>; + +export type PerpetualMarketOrderbookLevel = OrderbookLine & { + side?: 'ask' | 'bid'; + mine: number; + key: string; +}; diff --git a/src/constants/orderbook.ts b/src/constants/orderbook.ts new file mode 100644 index 0000000..3e8b490 --- /dev/null +++ b/src/constants/orderbook.ts @@ -0,0 +1,15 @@ +/** + * @description Orderbook display constants + */ +export const ORDERBOOK_MAX_ROWS_PER_SIDE = 30; +export const ORDERBOOK_ANIMATION_DURATION = 400; + +/** + * @description Orderbook pixel constants + * @note ORDERBOOK_ROW_HEIGHT should be a divisor of ORDERBOOK_HEIGHT so that we do not have a partial row at the bottom + * @note To change the Orderbook width, --orderbook-trades-width and ORDERBOOK_WIDTH must be changed to the same value + */ +export const ORDERBOOK_HEIGHT = 800; +export const ORDERBOOK_WIDTH = 300; +export const ORDERBOOK_ROW_HEIGHT = 24; +export const ORDERBOOK_ROW_PADDING_RIGHT = 8; diff --git a/src/hooks/Orderbook/index.ts b/src/hooks/Orderbook/index.ts new file mode 100644 index 0000000..e84b9d8 --- /dev/null +++ b/src/hooks/Orderbook/index.ts @@ -0,0 +1,4 @@ +export * from './useCenterOrderbook'; +export * from './useSpreadRowScrollListener'; +export * from './useDrawOrderbook'; +export * from './useOrderbookValues'; diff --git a/src/hooks/Orderbook/useCenterOrderbook.ts b/src/hooks/Orderbook/useCenterOrderbook.ts new file mode 100644 index 0000000..5161317 --- /dev/null +++ b/src/hooks/Orderbook/useCenterOrderbook.ts @@ -0,0 +1,22 @@ +import { useEffect, type RefObject } from 'react'; + +type Props = { + marketId: string; + orderbookRef: RefObject; +}; + +/** + * @description Center Orderbook on load and market change + * Assumed that the two sides are the same height + */ +export const useCenterOrderbook = ({ marketId, orderbookRef }: Props) => { + const orderbookEl = orderbookRef.current; + const { clientHeight, scrollHeight } = orderbookEl ?? {}; + const shouldScroll = scrollHeight && clientHeight && scrollHeight > clientHeight; + + useEffect(() => { + if (orderbookEl && shouldScroll) { + orderbookEl.scrollTo({ top: (scrollHeight - clientHeight) / 2 }); + } + }, [shouldScroll, marketId]); +}; diff --git a/src/hooks/Orderbook/useDrawOrderbook.ts b/src/hooks/Orderbook/useDrawOrderbook.ts new file mode 100644 index 0000000..887d3f3 --- /dev/null +++ b/src/hooks/Orderbook/useDrawOrderbook.ts @@ -0,0 +1,373 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; + +import type { PerpetualMarketOrderbookLevel } from '@/constants/abacus'; +import { SMALL_USD_DECIMALS, TOKEN_DECIMALS } from '@/constants/numbers'; + +import { + ORDERBOOK_ANIMATION_DURATION, + ORDERBOOK_HEIGHT, + ORDERBOOK_ROW_HEIGHT, + ORDERBOOK_ROW_PADDING_RIGHT, + ORDERBOOK_WIDTH, +} from '@/constants/orderbook'; + +import { getCurrentMarketConfig, getCurrentMarketOrderbookMap } from '@/state/perpetualsSelectors'; +import { getAppTheme } from '@/state/configsSelectors'; + +import { MustBigNumber } from '@/lib/numbers'; + +import { + getHistogramXValues, + getRektFromIdx, + getXByColumn, + getYForElements, +} from '@/lib/orderbookHelpers'; +import { useAppThemeContext } from '../useAppTheme'; + +type ElementProps = { + data: Array; + histogramRange: number; + side: PerpetualMarketOrderbookLevel['side']; +}; + +type StyleProps = { + histogramSide: 'left' | 'right'; +}; + +enum OrderbookRowAnimationType { + REMOVE, + NEW, + NONE, +} + +export const useDrawOrderbook = ({ + data, + histogramRange, + histogramSide, + side, +}: ElementProps & StyleProps) => { + const canvasRef = useRef(null); + const canvas = canvasRef.current; + const currentOrderbookMap = useSelector(getCurrentMarketOrderbookMap, shallowEqual); + const { stepSizeDecimals = TOKEN_DECIMALS, tickSizeDecimals = SMALL_USD_DECIMALS } = + useSelector(getCurrentMarketConfig, shallowEqual) || {}; + const prevData = useRef(data); + const theme = useAppThemeContext(); + + /** + * Scale canvas using device pixel ratio to unblur drawn text + * @url https://stackoverflow.com/questions/15661339/how-do-i-fix-blurry-text-in-my-html5-canvas/65124939#65124939 + * @returns adjusted canvas width/height/rowHeight used in coordinates for drawing + **/ + const { canvasWidth, canvasHeight, rowHeight } = useMemo(() => { + const ratio = window.devicePixelRatio || 1; + + if (!canvas) { + return { + canvasWidth: ORDERBOOK_WIDTH / devicePixelRatio, + canvasHeight: ORDERBOOK_HEIGHT / devicePixelRatio, + rowHeight: ORDERBOOK_ROW_HEIGHT / devicePixelRatio, + }; + } + + const ctx = canvas.getContext('2d'); + canvas.width = canvas.offsetWidth * ratio; + canvas.height = canvas.offsetHeight * ratio; + + if (ctx) { + ctx.scale(ratio, ratio); + ctx.font = '12px Satoshi'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.imageSmoothingQuality = 'high'; + } + + return { + canvasWidth: canvas.width / ratio, + canvasHeight: canvas.height / ratio, + rowHeight: ORDERBOOK_ROW_HEIGHT, + }; + }, [canvas]); + + const drawBars = ({ + barType, + ctx, + depthOrSizeValue, + gradientMultiplier, + histogramAccentColor, + histogramSide, + idx, + }: { + barType: 'depth' | 'size'; + ctx: CanvasRenderingContext2D; + depthOrSizeValue: number; + gradientMultiplier: number; + histogramAccentColor: string; + histogramSide: 'left' | 'right'; + idx: number; + }) => { + const { x1, x2, y1, y2 } = getRektFromIdx({ + idx, + rowHeight, + canvasWidth, + canvasHeight, + side, + }); + + // X values + const maxHistogramBarWidth = x2 - x1 - (barType === 'size' ? 8 : 2); + const barWidth = depthOrSizeValue + ? Math.min((depthOrSizeValue / histogramRange) * maxHistogramBarWidth, maxHistogramBarWidth) + : 0; + + const { gradient, bar } = getHistogramXValues({ + barWidth, + canvasWidth, + gradientMultiplier, + histogramSide, + }); + + // Gradient + let linearGradient; + + try { + linearGradient = ctx.createLinearGradient(gradient.x1, y1, gradient.x2, y2); + linearGradient.addColorStop(0, histogramAccentColor); + linearGradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); + ctx.fillStyle = linearGradient; + } catch (err) { + ctx.fillStyle = 'rgba(0, 0, 0, 0)'; + } + + ctx.beginPath(); + + // Bar + const { bar: y } = getYForElements({ y: y1, rowHeight }); + + if (ctx.roundRect) { + ctx.roundRect( + bar.x1, + y, + bar.x2, + rowHeight - 2, + histogramSide === 'right' ? [2, 0, 0, 2] : [0, 2, 2, 0] + ); + } else { + ctx.rect(bar.x1, y, bar.x2, rowHeight - 2); + } + + ctx.fill(); + }; + + const drawText = ({ + animationType = OrderbookRowAnimationType.NONE, + ctx, + idx, + mine, + price, + size, + }: { + animationType?: OrderbookRowAnimationType; + ctx: CanvasRenderingContext2D; + idx: number; + mine?: number; + price?: number; + size?: number; + }) => { + const { y1 } = getRektFromIdx({ + idx, + rowHeight, + canvasWidth, + canvasHeight, + side, + }); + + const { text: y } = getYForElements({ y: y1, rowHeight }); + + let textColor: string; + let updatedTextColor: string | undefined; + + switch (animationType) { + case OrderbookRowAnimationType.REMOVE: { + textColor = theme.textSecondary; + break; + } + + case OrderbookRowAnimationType.NEW: { + updatedTextColor = side === 'bid' ? theme.positive : theme.negative; + textColor = theme.textPrimary; + break; + } + + default: { + textColor = theme.textPrimary; + break; + } + } + + // Size text + if (size) { + ctx.fillStyle = updatedTextColor ?? textColor; + ctx.fillText( + MustBigNumber(size).toFixed(stepSizeDecimals ?? TOKEN_DECIMALS), + getXByColumn({ canvasWidth, colIdx: 0 }) - ORDERBOOK_ROW_PADDING_RIGHT, + y + ); + } + + // Price text + if (price) { + ctx.fillStyle = textColor; + ctx.fillText( + MustBigNumber(price).toFixed(tickSizeDecimals ?? SMALL_USD_DECIMALS), + getXByColumn({ canvasWidth, colIdx: 1 }) - ORDERBOOK_ROW_PADDING_RIGHT, + y + ); + } + + // Mine text + if (mine) { + ctx.fillStyle = textColor; + ctx.fillText( + MustBigNumber(mine).toFixed(stepSizeDecimals ?? TOKEN_DECIMALS), + getXByColumn({ canvasWidth, colIdx: 2 }) - ORDERBOOK_ROW_PADDING_RIGHT, + y + ); + } + }; + + const drawOrderbookRow = ({ + ctx, + idx, + rowToRender, + animationType = OrderbookRowAnimationType.NONE, + }: { + ctx: CanvasRenderingContext2D; + idx: number; + rowToRender?: PerpetualMarketOrderbookLevel; + animationType?: OrderbookRowAnimationType; + }) => { + if (!rowToRender) return; + const { depth, mine, price, size } = rowToRender; + const histogramAccentColor = side === 'bid' ? theme.positiveFaded : theme.negativeFaded; + + // Depth Bar + if (depth) { + drawBars({ + barType: 'depth', + ctx, + depthOrSizeValue: depth, + gradientMultiplier: 1.3, + histogramAccentColor, + histogramSide, + idx, + }); + } + + // Size Bar + drawBars({ + barType: 'size', + ctx, + depthOrSizeValue: size, + gradientMultiplier: 5, + histogramAccentColor, + histogramSide, + idx, + }); + + // Size, Price, Mine + drawText({ + animationType, + ctx, + idx, + mine, + price, + size, + }); + }; + + // Update histograms and row contents on data change + useEffect(() => { + const ctx = canvas?.getContext('2d'); + + if (!canvas || !ctx) return; + + // Clear canvas before redraw + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + + // Animate row removal and update + const mapOfOrderbookPriceLevels = + side && currentOrderbookMap?.[side === 'ask' ? 'asks' : 'bids']; + const empty: number[] = []; + const removed: number[] = []; + const updated: number[] = []; + + prevData.current.forEach((row, idx) => { + if (!row) { + empty.push(idx); + return; + } + + if (mapOfOrderbookPriceLevels?.[row.price] === 0) { + removed.push(idx); + drawOrderbookRow({ + ctx, + idx, + rowToRender: row, + animationType: OrderbookRowAnimationType.REMOVE, + }); + } else if (mapOfOrderbookPriceLevels?.[row.price] === row?.size) { + drawOrderbookRow({ + ctx, + idx, + rowToRender: data[idx], + animationType: OrderbookRowAnimationType.NONE, + }); + } else { + updated.push(idx); + drawOrderbookRow({ + ctx, + idx, + rowToRender: row, + animationType: OrderbookRowAnimationType.NEW, + }); + } + }); + + setTimeout(() => { + [...empty, ...removed, ...updated].forEach((idx) => { + const { x1, y1, x2, y2 } = getRektFromIdx({ + idx, + rowHeight, + canvasWidth, + canvasHeight, + side, + }); + + ctx.clearRect(x1, y1, x2 - x1, y2 - y1); + drawOrderbookRow({ + ctx, + idx, + rowToRender: data[idx], + }); + }); + }, ORDERBOOK_ANIMATION_DURATION); + + prevData.current = data; + }, [ + canvasHeight, + canvasWidth, + rowHeight, + data, + histogramRange, + stepSizeDecimals, + tickSizeDecimals, + histogramSide, + side, + theme, + currentOrderbookMap, + ]); + + return { canvasRef }; +}; diff --git a/src/hooks/Orderbook/useOrderbookValues.ts b/src/hooks/Orderbook/useOrderbookValues.ts new file mode 100644 index 0000000..d4418ed --- /dev/null +++ b/src/hooks/Orderbook/useOrderbookValues.ts @@ -0,0 +1,126 @@ +import { useMemo } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import { OrderSide } from '@dydxprotocol/v4-client-js'; + +import { type PerpetualMarketOrderbookLevel } from '@/constants/abacus'; +import { DepthChartSeries, DepthChartDatum } from '@/constants/charts'; + +import { getCurrentMarketOrderbook } from '@/state/perpetualsSelectors'; +import { getSubaccountOpenOrdersBySideAndPrice } from '@/state/accountSelectors'; + +import { MustBigNumber } from '@/lib/numbers'; + +export const useCalculateOrderbookData = ({ maxRowsPerSide }: { maxRowsPerSide: number }) => { + const orderbook = useSelector(getCurrentMarketOrderbook, shallowEqual); + + const openOrdersBySideAndPrice = + useSelector(getSubaccountOpenOrdersBySideAndPrice, shallowEqual) || {}; + + return useMemo(() => { + const asks: Array = ( + orderbook?.asks?.toArray() ?? [] + ) + .map( + (row, idx: number) => + ({ + key: `ask-${idx}`, + side: 'ask', + mine: openOrdersBySideAndPrice[OrderSide.SELL]?.[row.price]?.size, + ...row, + } as PerpetualMarketOrderbookLevel) + ) + .slice(0, maxRowsPerSide); + + const bids: Array = ( + orderbook?.bids?.toArray() ?? [] + ) + .map( + (row, idx: number) => + ({ + key: `bid-${idx}`, + side: 'bid', + mine: openOrdersBySideAndPrice[OrderSide.BUY]?.[row.price]?.size, + ...row, + } as PerpetualMarketOrderbookLevel) + ) + .slice(0, maxRowsPerSide); + + // Prevent the bid/ask sides from crossing by using the offsets. + // While the books are crossing... + while (asks[0] && bids[0] && bids[0]!.price >= asks[0].price) { + // Drop the order on the side with the lower offset. + // The offset of the other side is higher and so supercedes. + if (bids[0]!.offset === asks[0].offset) { + // If offsets are the same, give precedence to the larger size. In this case, + // one of the sizes *should* be zero, but we simply check for the larger size. + if (bids[0]!.size > asks[0].size) { + asks.shift(); + } else { + bids.pop(); + } + } else { + // Offsets are not equal. Give precedence to the larger offset. + if (bids[0]!.offset > asks[0].offset) { + asks.shift(); + } else { + bids.pop(); + } + } + } + + const spread = + asks[0]?.price && bids[0]?.price ? MustBigNumber(asks[0].price).minus(bids[0].price) : null; + + const spreadPercent = orderbook?.spreadPercent; + + const histogramRange = Math.max( + isNaN(Number(bids[bids.length - 1]?.depth)) ? 0 : Number(bids[bids.length - 1]?.depth), + isNaN(Number(asks[asks.length - 1]?.depth)) ? 0 : Number(asks[asks.length - 1]?.depth) + ); + + return { + asks, + bids, + spread, + spreadPercent, + histogramRange, + hasOrderbook: !!orderbook, + }; + }, [orderbook, openOrdersBySideAndPrice]); +}; + +export const useOrderbookValuesForDepthChart = () => { + const orderbook = useSelector(getCurrentMarketOrderbook, shallowEqual); + + return useMemo(() => { + const bids = (orderbook?.bids?.toArray() ?? []) + .filter(Boolean) + .map((datum) => ({ ...datum, seriesKey: DepthChartSeries.Bids } as DepthChartDatum)); + + const asks = (orderbook?.asks?.toArray() ?? []) + .filter(Boolean) + .map((datum) => ({ ...datum, seriesKey: DepthChartSeries.Asks } as DepthChartDatum)); + + const lowestBid = bids[bids.length - 1]; + const highestBid = bids[0]; + const lowestAsk = asks[0]; + const highestAsk = asks[asks.length - 1]; + + const midMarketPrice = orderbook?.midPrice; + const spread = MustBigNumber(lowestAsk?.price ?? 0).minus(highestBid?.price ?? 0); + const spreadPercent = orderbook?.spreadPercent; + + return { + bids, + asks, + lowestBid, + highestBid, + lowestAsk, + highestAsk, + midMarketPrice, + spread, + spreadPercent, + orderbook, + }; + }, [orderbook]); +}; diff --git a/src/hooks/Orderbook/useSpreadRowScrollListener.ts b/src/hooks/Orderbook/useSpreadRowScrollListener.ts new file mode 100644 index 0000000..e82c0b7 --- /dev/null +++ b/src/hooks/Orderbook/useSpreadRowScrollListener.ts @@ -0,0 +1,39 @@ +import { type RefObject, useEffect, useState } from 'react'; + +export const useSpreadRowScrollListener = ({ + orderbookRef, + spreadRowRef, +}: { + orderbookRef: RefObject; + spreadRowRef: RefObject; +}) => { + const [displaySide, setDisplaySide] = useState(); + + useEffect(() => { + const onScroll = () => { + if (spreadRowRef.current && orderbookRef.current) { + const { clientHeight } = orderbookRef.current; + const parent = orderbookRef.current.getBoundingClientRect(); + const spread = spreadRowRef.current.getBoundingClientRect(); + const spreadTop = spread.top - parent.top; + const spreadBottom = spread.bottom - parent.top; + + if (spreadBottom > clientHeight) { + setDisplaySide('bottom'); + } else if (spreadTop < 0) { + setDisplaySide('top'); + } else { + setDisplaySide(undefined); + } + } + }; + + orderbookRef.current?.addEventListener('scroll', onScroll, false); + + return () => { + orderbookRef.current?.removeEventListener('scroll', onScroll, false); + }; + }, [orderbookRef.current, spreadRowRef.current]); + + return displaySide; +}; diff --git a/src/hooks/useOrderbookValues.ts b/src/hooks/useOrderbookValues.ts deleted file mode 100644 index 7e65a47..0000000 --- a/src/hooks/useOrderbookValues.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useMemo } from 'react'; -import { shallowEqual, useSelector } from 'react-redux'; - -import { DepthChartSeries, DepthChartDatum } from '@/constants/charts'; - -import { getCurrentMarketOrderbook } from '@/state/perpetualsSelectors'; - -import { MustBigNumber } from '@/lib/numbers'; - -export const useOrderbookValuesForDepthChart = () => { - const orderbook = useSelector(getCurrentMarketOrderbook, shallowEqual); - - return useMemo(() => { - const bids = (orderbook?.bids?.toArray() ?? []) - .filter(Boolean) - .map((datum) => ({ ...datum, seriesKey: DepthChartSeries.Bids } as DepthChartDatum)); - - const asks = (orderbook?.asks?.toArray() ?? []) - .filter(Boolean) - .map((datum) => ({ ...datum, seriesKey: DepthChartSeries.Asks } as DepthChartDatum)); - - const lowestBid = bids[bids.length - 1]; - const highestBid = bids[0]; - const lowestAsk = asks[0]; - const highestAsk = asks[asks.length - 1]; - - const midMarketPrice = orderbook?.midPrice; - const spread = MustBigNumber(lowestAsk?.price ?? 0).minus(highestBid?.price ?? 0); - const spreadPercent = orderbook?.spreadPercent; - - return { - bids, - asks, - lowestBid, - highestBid, - lowestAsk, - highestAsk, - midMarketPrice, - spread, - spreadPercent, - orderbook, - }; - }, [orderbook]); -}; diff --git a/src/index.css b/src/index.css index fc5919b..3b5a8b7 100644 --- a/src/index.css +++ b/src/index.css @@ -8,6 +8,7 @@ background-color: var(--color-layer-2); user-select: none; overflow: hidden; + --border: var(--default-border-width) solid var(--color-border); } body, @@ -53,7 +54,7 @@ button:disabled { cursor: pointer; } -:focus-visible{ +:focus-visible { outline: var(--color-accent) 1px solid; outline-offset: -1px; /* outline-offset: -2px; */ diff --git a/src/lib/orderbookHelpers.ts b/src/lib/orderbookHelpers.ts new file mode 100644 index 0000000..45b49de --- /dev/null +++ b/src/lib/orderbookHelpers.ts @@ -0,0 +1,133 @@ +// ------ Canvas helper methods ------ // + +import type { MarketOrderbook, Nullable, PerpetualMarketOrderbookLevel } from '@/constants/abacus'; + +/** + * @returns top left x,y and bottom x,y from array idx + */ +export const getRektFromIdx = ({ + idx, + canvasWidth, + canvasHeight, + rowHeight, + side = 'bid', +}: { + idx: number; + canvasWidth: number; + canvasHeight: number; + rowHeight: number; + side: PerpetualMarketOrderbookLevel['side']; +}) => { + /** + * Does not change + */ + const x1 = 0; + const x2 = Math.floor(canvasWidth); + + /** + * Changes based on side + * Asks: The drawing should starts from the bottom of the orderbook + */ + if (side === 'ask') { + const y1 = Math.floor(canvasHeight - (idx + 1) * rowHeight); + const y2 = Math.floor(y1 + rowHeight); + return { x1, x2, y1, y2 }; + } + + const y1 = Math.floor(idx * rowHeight); + const y2 = Math.floor(y1 + rowHeight); + return { x1, x2, y1, y2 }; +}; + +/** + * @returns y value for text and bar + */ +export const getYForElements = ({ y, rowHeight }: { y: number; rowHeight: number }) => ({ + text: Math.floor(y + rowHeight / 2), + bar: Math.floor(y + 1), +}); + +/** + * @description X coordinate for text + * @returns Get X value by column, colIdx starts at 0 + */ +export const getXByColumn = ({ canvasWidth, colIdx }: { canvasWidth: number; colIdx: number }) => { + return Math.floor(((colIdx + 1) * canvasWidth) / 3); +}; + +/** + * to === 'right' to === 'left' + * |===================| |===================| + * bar: a b c d + * a = 0 + * b = depthBarWidth + * c = canvasWidth - depthBarWidth + * d = canvasWidth + * + */ +export const getHistogramXValues = ({ + barWidth, + canvasWidth, + gradientMultiplier, + histogramSide, +}: { + barWidth: number; + canvasWidth: number; + gradientMultiplier: number; + histogramSide: 'left' | 'right'; +}) => { + const gradient = { + x1: Math.floor(histogramSide === 'left' ? barWidth : canvasWidth - barWidth), + x2: Math.floor( + histogramSide === 'left' + ? 0 - (canvasWidth * gradientMultiplier - canvasWidth) + : canvasWidth * gradientMultiplier + ), + }; + const bar = { + x1: Math.floor(histogramSide === 'left' ? 0 : canvasWidth - barWidth), + x2: Math.floor(histogramSide === 'left' ? Math.min(barWidth, canvasWidth) : canvasWidth), + }; + + return { + bar, + gradient, + }; +}; + +// ------ Orderbook helper methods ------ // +export const processOrderbookToCreateMap = ({ + orderbookMap, + newOrderbook, +}: { + orderbookMap?: { + asks: Record; + bids: Record; + }; + newOrderbook: Nullable; +}) => { + // Create Orderbooks Map indexed by Price + const asks = newOrderbook?.asks?.toArray() ?? []; + const bids = newOrderbook?.bids?.toArray() ?? []; + const prevAsks = orderbookMap?.asks ?? {}; + const prevBids = orderbookMap?.bids ?? {}; + const newAsks = Object.fromEntries(asks.map((ask) => [ask.price, ask.size]) ?? {}); + const newBids = Object.fromEntries(bids.map((bid) => [bid.price, bid.size]) ?? {}); + + Object.keys(prevAsks).forEach((price) => { + if (!newAsks[price] || newAsks[price] === 0) { + newAsks[price] = 0; + } + }); + + Object.keys(prevBids).forEach((price) => { + if (!newBids[price] || newBids[price] === 0) { + newBids[price] = 0; + } + }); + + return { + newAsks, + newBids, + }; +}; diff --git a/src/pages/trade/VerticalPanel.tsx b/src/pages/trade/VerticalPanel.tsx index d35848c..7a7af7d 100644 --- a/src/pages/trade/VerticalPanel.tsx +++ b/src/pages/trade/VerticalPanel.tsx @@ -1,6 +1,4 @@ -import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; -import styled, { AnyStyledComponent } from 'styled-components'; +import { useState } from 'react'; import { TradeLayouts } from '@/constants/layout'; import { STRING_KEYS } from '@/constants/localization'; @@ -9,11 +7,9 @@ import { useStringGetter } from '@/hooks'; import { Tabs } from '@/components/Tabs'; -import { Orderbook, orderbookMixins, OrderbookScrollBehavior } from '@/views/tables/Orderbook'; +import { CanvasOrderbook } from '@/views/CanvasOrderbook'; import { LiveTrades } from '@/views/tables/LiveTrades'; -import { getCurrentMarketId } from '@/state/perpetualsSelectors'; - enum Tab { Orderbook = 'Orderbook', Trades = 'Trades', @@ -27,29 +23,22 @@ const HISTOGRAM_SIDES_BY_LAYOUT = { export const VerticalPanel = ({ tradeLayout }: { tradeLayout: TradeLayouts }) => { const stringGetter = useStringGetter(); - const [value, setValue] = useState(Tab.Orderbook); - const [scrollBehavior, setScrollBehavior] = useState('snapToCenter'); - - const marketId = useSelector(getCurrentMarketId); - - useEffect(() => { - setScrollBehavior('snapToCenter'); - }, [marketId]); return ( - { - setScrollBehavior('snapToCenter'); setValue(value); }} items={[ { - content: , + asChild: true, + content: , label: stringGetter({ key: STRING_KEYS.ORDERBOOK_SHORT }), value: Tab.Orderbook, + forceMount: true, }, { content: , @@ -57,26 +46,7 @@ export const VerticalPanel = ({ tradeLayout }: { tradeLayout: TradeLayouts }) => value: Tab.Trades, }, ]} - onWheel={() => setScrollBehavior('free')} withTransitions={false} - isShowingOrderbook={value === Tab.Orderbook} - scrollBehavior={scrollBehavior} - // style={{ - // scrollSnapType: { - // 'snapToCenter': 'y mandatory', - // 'free': 'none', - // 'snapToCenterUnlessHovered': 'none', - // }[scrollBehavior], - // }} /> ); }; - -const Styled: Record = {}; - -Styled.Tabs = styled(Tabs)<{ - isShowingOrderbook: boolean; - scrollBehavior: OrderbookScrollBehavior; -}>` - ${orderbookMixins.scrollArea} -`; diff --git a/src/state/perpetuals.ts b/src/state/perpetuals.ts index 848b3d8..4a9f675 100644 --- a/src/state/perpetuals.ts +++ b/src/state/perpetuals.ts @@ -14,6 +14,7 @@ import { LocalStorageKey } from '@/constants/localStorage'; import { DEFAULT_MARKETID } from '@/constants/markets'; import { getLocalStorage } from '@/lib/localStorage'; +import { processOrderbookToCreateMap } from '@/lib/orderbookHelpers'; interface CandleDataByMarket { data: Record; @@ -26,6 +27,13 @@ export interface PerpetualsState { liveTrades?: Record; markets?: Record; orderbooks?: Record; + orderbooksMap?: Record< + string, + { + asks: Record; + bids: Record; + } + >; historicalFundings: Record; } @@ -35,6 +43,7 @@ const initialState: PerpetualsState = { liveTrades: {}, markets: undefined, orderbooks: undefined, + orderbooksMap: undefined, historicalFundings: {}, }; @@ -96,12 +105,24 @@ export const perpetualsSlice = createSlice({ setOrderbook: ( state: PerpetualsState, action: PayloadAction<{ orderbook?: Nullable; marketId: string }> - ) => ({ - ...state, - orderbooks: merge({}, state.orderbooks, { + ) => { + state.orderbooks = merge({}, state.orderbooks, { [action.payload.marketId]: action.payload.orderbook, - }), - }), + }); + + const { newAsks, newBids } = processOrderbookToCreateMap({ + orderbookMap: state.orderbooksMap?.[action.payload.marketId], + newOrderbook: action.payload.orderbook, + }); + + state.orderbooksMap = { + ...(state.orderbooksMap ?? {}), + [action.payload.marketId]: { + asks: newAsks, + bids: newBids, + }, + }; + }, setTvChartResolution: ( state: PerpetualsState, action: PayloadAction<{ marketId: string; resolution: string }> diff --git a/src/state/perpetualsSelectors.ts b/src/state/perpetualsSelectors.ts index 2bb2f51..0c02a6b 100644 --- a/src/state/perpetualsSelectors.ts +++ b/src/state/perpetualsSelectors.ts @@ -7,7 +7,6 @@ import { mapCandle } from '@/lib/tradingView/utils'; import type { RootState } from './_store'; /** - * @param state * @returns marketId of the market the user is currently viewing */ export const getCurrentMarketId = (state: RootState) => state.perpetuals.currentMarketId; @@ -21,7 +20,6 @@ export const getCurrentMarketAssetId = (state: RootState) => { }; /** - * @param state * @returns Record of PerpetualMarket indexed by MarketId */ export const getPerpetualMarkets = (state: RootState) => state.perpetuals.markets; @@ -34,13 +32,11 @@ export const getMarketData = (marketId: string) => (state: RootState) => getPerpetualMarkets(state)?.[marketId]; /** - * @param state * @returns marketIds of all markets */ export const getMarketIds = (state: RootState) => Object.keys(getPerpetualMarkets(state) ?? []); /** - * @param state * @returns PerpetualMarket data of the market the user is currently viewing */ export const getCurrentMarketData = (state: RootState) => { @@ -49,7 +45,6 @@ export const getCurrentMarketData = (state: RootState) => { }; /** - * @param state * @returns MarketConfig data of the market the user is currently viewing */ export const getCurrentMarketConfig = (state: RootState) => getCurrentMarketData(state)?.configs; @@ -62,13 +57,11 @@ export const getMarketConfig = (marketId: string) => (state: RootState) => getMarketData(marketId)(state)?.configs; /** - * @param state * @returns Record of list of MarketTrades indexed by marketId */ export const getLiveTrades = (state: RootState) => state.perpetuals.liveTrades; /** - * @param state * @returns List of MarketTrades of the market the user is currently viewing */ export const getCurrentMarketLiveTrades = (state: RootState) => { @@ -78,13 +71,11 @@ export const getCurrentMarketLiveTrades = (state: RootState) => { }; /** - * @param state * @returns Record of subscribed or previously subscribed Orderbook data, indexed by marketId. */ export const getOrderbooks = (state: RootState) => state.perpetuals.orderbooks; /** - * @param state * @returns Orderbook data for the market the user is currently viewing */ export const getCurrentMarketOrderbook = (state: RootState) => { @@ -94,13 +85,20 @@ export const getCurrentMarketOrderbook = (state: RootState) => { }; /** - * @param state + * @returns Orderbook data as a Map of price and size for the current market + */ +export const getCurrentMarketOrderbookMap = (state: RootState) => { + const orderbookMap = state.perpetuals.orderbooksMap; + const currentMarketId = getCurrentMarketId(state); + return orderbookMap?.[currentMarketId || '']; +}; + +/** * @returns Record of historical funding indexed by marketId. */ export const getHistoricalFundings = (state: RootState) => state.perpetuals.historicalFundings; /** - * @param state * @returns Historical funding data for the market the user is currently viewing */ export const getCurrentMarketHistoricalFundings = createSelector( @@ -110,7 +108,6 @@ export const getCurrentMarketHistoricalFundings = createSelector( ); /** - * @param state * @returns Mid market price for the market the user is currently viewing */ export const getCurrentMarketMidMarketPrice = (state: RootState) => { @@ -150,7 +147,6 @@ export const getSelectedResolutionForMarket = (marketId: string) => (state: Root state.perpetuals.candles?.[marketId]?.selectedResolution; /** - * @param state * @returns Current market's next funding rate */ export const getCurrentMarketNextFundingRate = createSelector( diff --git a/src/styles/themes.ts b/src/styles/themes.ts index ec96986..4b910c5 100644 --- a/src/styles/themes.ts +++ b/src/styles/themes.ts @@ -1,5 +1,5 @@ import { AppTheme } from '@/state/configs'; -import { ThemeColors } from '@/constants/styles/colors'; +import type { ThemeColors } from '@/constants/styles/colors'; import { ColorToken, OpacityToken } from '@/constants/styles/base'; import { generateFadedColorVariant } from '@/lib/styles'; diff --git a/src/views/CanvasOrderbook/OrderbookRow.tsx b/src/views/CanvasOrderbook/OrderbookRow.tsx new file mode 100644 index 0000000..31a3b69 --- /dev/null +++ b/src/views/CanvasOrderbook/OrderbookRow.tsx @@ -0,0 +1,81 @@ +import { forwardRef } from 'react'; +import styled, { AnyStyledComponent, css } from 'styled-components'; + +import type { Nullable } from '@/constants/abacus'; +import { STRING_KEYS } from '@/constants/localization'; +import { TOKEN_DECIMALS } from '@/constants/numbers'; +import { ORDERBOOK_ROW_HEIGHT } from '@/constants/orderbook'; +import { useStringGetter } from '@/hooks'; + +import { Output, OutputType } from '@/components/Output'; +import { WithTooltip } from '@/components/WithTooltip'; + +type StyleProps = { + side?: 'top' | 'bottom'; +}; + +type ElementProps = { + spread?: Nullable; + spreadPercent?: Nullable; + tickSizeDecimals?: Nullable; +}; + +export const OrderbookRow = styled.div` + display: flex; + flex-shrink: 0; + align-items: center; + height: ${ORDERBOOK_ROW_HEIGHT}px; + min-height: ${ORDERBOOK_ROW_HEIGHT}px; + position: relative; + padding-right: 0.5rem; + font: var(--font-mini-book); + + > span { + flex: 1 1 0%; + text-align: right; + } +`; + +export const SpreadRow = forwardRef( + ({ side, spread, spreadPercent, tickSizeDecimals = TOKEN_DECIMALS }, ref) => { + const stringGetter = useStringGetter(); + + return ( + + + + {stringGetter({ key: STRING_KEYS.ORDERBOOK_SPREAD })} + + + + + + + + + + ); + } +); + +const Styled: Record = {}; + +Styled.SpreadRow = styled(OrderbookRow)<{ side?: 'top' | 'bottom' }>` + height: 2rem; + border-top: var(--border); + border-bottom: var(--border); + span { + margin-bottom: 2px; + } + + ${({ side }) => + side && + { + top: css` + border-top: none; + `, + bottom: css` + border-bottom: none; + `, + }[side]} +`; diff --git a/src/views/CanvasOrderbook/index.tsx b/src/views/CanvasOrderbook/index.tsx new file mode 100644 index 0000000..fa0442a --- /dev/null +++ b/src/views/CanvasOrderbook/index.tsx @@ -0,0 +1,284 @@ +import { forwardRef, useCallback, useMemo, useRef } from 'react'; +import styled, { AnyStyledComponent, css } from 'styled-components'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; + +import { Nullable, type PerpetualMarketOrderbookLevel } from '@/constants/abacus'; +import { STRING_KEYS } from '@/constants/localization'; +import { ORDERBOOK_HEIGHT, ORDERBOOK_MAX_ROWS_PER_SIDE } from '@/constants/orderbook'; +import { useStringGetter } from '@/hooks'; + +import { + useCalculateOrderbookData, + useCenterOrderbook, + useDrawOrderbook, + useSpreadRowScrollListener, +} from '@/hooks/Orderbook'; + +import { Canvas } from '@/components/Canvas'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { Tag } from '@/components/Tag'; + +import { getCurrentMarketAssetData } from '@/state/assetsSelectors'; +import { getCurrentMarketConfig, getCurrentMarketId } from '@/state/perpetualsSelectors'; +import { setTradeFormInputs } from '@/state/inputs'; +import { getCurrentInput } from '@/state/inputsSelectors'; + +import { OrderbookRow, SpreadRow } from './OrderbookRow'; + +type ElementProps = { + maxRowsPerSide?: number; + layout?: 'vertical' | 'horizontal'; +}; + +type StyleProps = { + hideHeader?: boolean; + histogramSide?: 'left' | 'right'; + className?: string; +}; + +export const CanvasOrderbook = forwardRef( + ( + { + histogramSide = 'right', + maxRowsPerSide = ORDERBOOK_MAX_ROWS_PER_SIDE, + }: ElementProps & StyleProps, + ref + ) => { + const { asks, bids, hasOrderbook, histogramRange, spread, spreadPercent } = + useCalculateOrderbookData({ + maxRowsPerSide, + }); + + const stringGetter = useStringGetter(); + const currentMarket = useSelector(getCurrentMarketId) ?? ''; + const currentMarketConfig = useSelector(getCurrentMarketConfig, shallowEqual); + const { id = '' } = useSelector(getCurrentMarketAssetData, shallowEqual) ?? {}; + + const { tickSizeDecimals = 2 } = currentMarketConfig ?? {}; + + /** + * Slice asks and bids to maxRowsPerSide using empty rows + */ + const { asksSlice, bidsSlice } = useMemo(() => { + let asksSlice: Array = []; + const emptyAskRows = + asks.length < maxRowsPerSide + ? new Array(maxRowsPerSide - asks.length).fill(undefined) + : []; + asksSlice = [...emptyAskRows, ...asks.reverse()]; + + let bidsSlice: Array = []; + const emptyBidRows = + bids.length < maxRowsPerSide + ? new Array(maxRowsPerSide - bids.length).fill(undefined) + : []; + bidsSlice = [...bids, ...emptyBidRows]; + + return { + asksSlice, + bidsSlice, + }; + }, [asks, bids]); + + const orderbookRef = useRef(null); + useCenterOrderbook({ orderbookRef, marketId: currentMarket }); + + /** + * Display top or bottom spreadRow when center spreadRow is off screen + */ + const spreadRowRef = useRef(null); + + const displaySide = useSpreadRowScrollListener({ + orderbookRef, + spreadRowRef, + }); + + /** + * Row action + */ + const currentInput = useSelector(getCurrentInput); + const dispatch = useDispatch(); + const onRowAction = useCallback( + (price: Nullable) => { + if (currentInput === 'trade' && price) { + dispatch(setTradeFormInputs({ limitPriceInput: price?.toString() })); + } + }, + [currentInput] + ); + + const { canvasRef: asksCanvasRef } = useDrawOrderbook({ + data: [...asksSlice].reverse(), + histogramRange, + histogramSide, + side: 'ask', + }); + + const { canvasRef: bidsCanvasRef } = useDrawOrderbook({ + data: bidsSlice, + histogramRange, + histogramSide, + side: 'bid', + }); + + return ( + + + + + {stringGetter({ key: STRING_KEYS.SIZE })} {id && {id}} + + + {stringGetter({ key: STRING_KEYS.PRICE })} USD + + {stringGetter({ key: STRING_KEYS.MINE })} + + + {displaySide === 'top' && ( + + )} + + + + + {asksSlice.map((row: PerpetualMarketOrderbookLevel | undefined, idx) => + row ? ( + { + onRowAction(row.price); + }} + /> + ) : ( + + ) + )} + + + + + + + + + {bidsSlice.map((row: PerpetualMarketOrderbookLevel | undefined, idx) => + row ? ( + { + onRowAction(row.price); + } + : undefined + } + /> + ) : ( + + ) + )} + + + + + {displaySide === 'bottom' && ( + + )} + + {!hasOrderbook && } + + ); + } +); + +const Styled: Record = {}; + +Styled.OrderbookContainer = styled.div` + display: flex; + flex: 1 1 0%; + flex-direction: column; + overflow: hidden; +`; + +Styled.OrderbookContent = styled.div<{ $isLoading?: boolean }>` + max-height: 100%; + display: flex; + flex-direction: column; + position: relative; + ${({ $isLoading }) => $isLoading && 'flex: 1;'} +`; + +Styled.Header = styled(OrderbookRow)` + height: 2rem; + border-bottom: var(--border); + color: var(--color-text-0); +`; + +Styled.OrderbookWrapper = styled.div` + overflow-y: auto; + display: flex; + flex-direction: column; + flex: 1 1 0%; +`; + +Styled.OrderbookSideContainer = styled.div<{ $side: 'bids' | 'asks' }>` + min-height: ${ORDERBOOK_HEIGHT}px; + ${({ $side }) => css` + --accent-color: ${$side === 'bids' ? 'var(--color-positive)' : 'var(--color-negative)'}; + `} + position: relative; +`; + +Styled.OrderbookCanvas = styled(Canvas)` + width: 100%; + height: 100%; + position: absolute; + top: 0; + right: 0; + pointer-events: none; + font-feature-settings: var(--fontFeature-monoNumbers); +`; + +Styled.HoverRows = styled.div<{ $bottom?: boolean }>` + position: absolute; + width: 100%; + + ${({ $bottom }) => $bottom && 'bottom: 0;'} +`; + +Styled.Row = styled(OrderbookRow)<{ onClick?: () => void }>` + ${({ onClick }) => + onClick + ? css` + cursor: pointer; + + &:hover { + background-color: var(--color-layer-4); + filter: darkness(0.1); + } + ` + : css` + cursor: default; + `} +`; + +Styled.SpreadRow = styled(SpreadRow)` + position: absolute; +`; diff --git a/src/views/charts/DepthChart/Tooltip.tsx b/src/views/charts/DepthChart/Tooltip.tsx index d3c2532..bcfc8b4 100644 --- a/src/views/charts/DepthChart/Tooltip.tsx +++ b/src/views/charts/DepthChart/Tooltip.tsx @@ -13,7 +13,7 @@ import { import { STRING_KEYS } from '@/constants/localization'; import { useStringGetter } from '@/hooks'; -import { useOrderbookValuesForDepthChart } from '@/hooks/useOrderbookValues'; +import { useOrderbookValuesForDepthChart } from '@/hooks/Orderbook/useOrderbookValues'; import { TooltipContent } from '@/components/visx/TooltipContent'; import { Details } from '@/components/Details'; diff --git a/src/views/charts/DepthChart/index.tsx b/src/views/charts/DepthChart/index.tsx index 3b125d7..b1d2101 100644 --- a/src/views/charts/DepthChart/index.tsx +++ b/src/views/charts/DepthChart/index.tsx @@ -12,7 +12,7 @@ import { import { StringGetterFunction } from '@/constants/localization'; import { useBreakpoints } from '@/hooks'; -import { useOrderbookValuesForDepthChart } from '@/hooks/useOrderbookValues'; +import { useOrderbookValuesForDepthChart } from '@/hooks/Orderbook/useOrderbookValues'; import { getCurrentMarketConfig } from '@/state/perpetualsSelectors'; import { getCurrentMarketAssetData } from '@/state/assetsSelectors'; @@ -83,18 +83,8 @@ export const DepthChart = ({ const { stepSizeDecimals, tickSizeDecimals } = useSelector(getCurrentMarketConfig, shallowEqual) ?? {}; - const { - bids, - asks, - lowestBid, - highestBid, - lowestAsk, - highestAsk, - midMarketPrice, - spread, - spreadPercent, - orderbook, - } = useOrderbookValuesForDepthChart(); + const { bids, asks, lowestBid, highestBid, lowestAsk, highestAsk, midMarketPrice, orderbook } = + useOrderbookValuesForDepthChart(); // Chart state diff --git a/src/views/tables/LiveTrades.tsx b/src/views/tables/LiveTrades.tsx index a4cf4b2..7dd3022 100644 --- a/src/views/tables/LiveTrades.tsx +++ b/src/views/tables/LiveTrades.tsx @@ -163,6 +163,7 @@ Styled.SizeOutput = styled(Output)` Styled.LiveTradesTable = styled(OrderbookTradesTable)` tr { --histogram-bucket-size: 1; + background-color: var(--color-layer-2); &[data-side=${OrderSide.BUY}] { --accent-color: var(--color-positive); diff --git a/src/views/tables/Orderbook.tsx b/src/views/tables/Orderbook.tsx index 4fbc983..86f5829 100644 --- a/src/views/tables/Orderbook.tsx +++ b/src/views/tables/Orderbook.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import styled, { type AnyStyledComponent, css, keyframes } from 'styled-components'; import { OrderSide } from '@dydxprotocol/v4-client-js'; -import { type OrderbookLine, TradeInputField } from '@/constants/abacus'; +import { type OrderbookLine } from '@/constants/abacus'; import { STRING_KEYS } from '@/constants/localization'; +import { ORDERBOOK_MAX_ROWS_PER_SIDE } from '@/constants/orderbook'; import { useBreakpoints, useStringGetter } from '@/hooks'; @@ -29,8 +30,6 @@ import { OrderbookTradesOutput, OrderbookTradesTable } from './OrderbookTradesTa import { breakpoints } from '@/styles'; import { layoutMixins } from '@/styles/layoutMixins'; -const ORDERBOOK_MAX_ROWS_PER_SIDE = 35; - type ElementProps = { maxRowsPerSide?: number; layout?: 'vertical' | 'horizontal';