fix(market-depth): fix order book priceInCenter calculation and scroll to price (#2901)

This commit is contained in:
Bartłomiej Głownia 2023-02-22 03:01:48 +01:00 committed by GitHub
parent ce6d4cb35d
commit 4bb57e9c47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 223 additions and 217 deletions

View File

@ -42,10 +42,18 @@ export const update: Update<
}, },
}; };
if (delta.buy) { if (delta.buy) {
updatedData.depth.buy = updateLevels(data.depth.buy ?? [], delta.buy); updatedData.depth.buy = updateLevels(
data.depth.buy ?? [],
delta.buy,
false
);
} }
if (delta.sell) { if (delta.sell) {
updatedData.depth.sell = updateLevels(data.depth.sell ?? [], delta.sell); updatedData.depth.sell = updateLevels(
data.depth.sell ?? [],
delta.sell,
true
);
} }
updatedData.depth.sequenceNumber = delta.sequenceNumber; updatedData.depth.sequenceNumber = delta.sequenceNumber;
return updatedData; return updatedData;

View File

@ -26,9 +26,19 @@ export interface OrderbookRowData {
type PartialOrderbookRowData = Pick<OrderbookRowData, 'price' | 'ask' | 'bid'>; type PartialOrderbookRowData = Pick<OrderbookRowData, 'price' | 'ask' | 'bid'>;
export type OrderbookData = Partial< type OrderbookMarketData = Pick<
Omit<MarketData, '__typename' | 'market'> MarketData,
> & { rows: OrderbookRowData[] | null }; | 'bestStaticBidPrice'
| 'bestStaticOfferPrice'
| 'indicativePrice'
| 'indicativeVolume'
| 'marketTradingMode'
>;
export type OrderbookData = Partial<OrderbookMarketData> & {
rows: OrderbookRowData[] | null;
midPrice?: string;
};
export const getPriceLevel = (price: string | bigint, resolution: number) => { export const getPriceLevel = (price: string | bigint, resolution: number) => {
const p = BigInt(price); const p = BigInt(price);
@ -40,6 +50,18 @@ export const getPriceLevel = (price: string | bigint, resolution: number) => {
return priceLevel.toString(); return priceLevel.toString();
}; };
export const getMidPrice = (
sell: PriceLevelFieldsFragment[] | null | undefined,
buy: PriceLevelFieldsFragment[] | null | undefined,
resolution: number
) =>
buy?.length && sell?.length
? getPriceLevel(
(BigInt(buy[0].price) + BigInt(sell[0].price)) / BigInt(2),
resolution
)
: undefined;
const getMaxVolumes = (orderbookData: OrderbookRowData[]) => ({ const getMaxVolumes = (orderbookData: OrderbookRowData[]) => ({
bid: Math.max(...orderbookData.map((data) => data.bid)), bid: Math.max(...orderbookData.map((data) => data.bid)),
ask: Math.max(...orderbookData.map((data) => data.ask)), ask: Math.max(...orderbookData.map((data) => data.ask)),
@ -157,8 +179,15 @@ export const compactRows = (
} }
orderbookData.push(row); orderbookData.push(row);
}); });
// order by price, it's safe to cast to number price diff should not exceed Number.MAX_SAFE_INTEGER orderbookData.sort((a, b) => {
orderbookData.sort((a, b) => Number(BigInt(b.price) - BigInt(a.price))); if (a === b) {
return 0;
}
if (BigInt(a.price) > BigInt(b.price)) {
return -1;
}
return 1;
});
// count cumulative volumes // count cumulative volumes
if (orderbookData.length > 1) { if (orderbookData.length > 1) {
const maxIndex = orderbookData.length - 1; const maxIndex = orderbookData.length - 1;
@ -253,28 +282,6 @@ export const updateCompactedRows = (
return data; return data;
}; };
export const mapMarketData = (
data: Pick<
MarketData,
| 'staticMidPrice'
| 'bestStaticBidPrice'
| 'bestStaticOfferPrice'
| 'indicativePrice'
> | null,
resolution: number
) => ({
staticMidPrice:
data?.staticMidPrice && getPriceLevel(data?.staticMidPrice, resolution),
bestStaticBidPrice:
data?.bestStaticBidPrice &&
getPriceLevel(data?.bestStaticBidPrice, resolution),
bestStaticOfferPrice:
data?.bestStaticOfferPrice &&
getPriceLevel(data?.bestStaticOfferPrice, resolution),
indicativePrice:
data?.indicativePrice && getPriceLevel(data?.indicativePrice, resolution),
});
/** /**
* Updates raw data with new data received from subscription - mutates input * Updates raw data with new data received from subscription - mutates input
* @param levels * @param levels
@ -283,7 +290,8 @@ export const mapMarketData = (
*/ */
export const updateLevels = ( export const updateLevels = (
draft: PriceLevelFieldsFragment[], draft: PriceLevelFieldsFragment[],
updates: (PriceLevelFieldsFragment | PriceLevelFieldsFragment)[] updates: (PriceLevelFieldsFragment | PriceLevelFieldsFragment)[],
ascending = true
) => { ) => {
const levels = [...draft]; const levels = [...draft];
updates.forEach((update) => { updates.forEach((update) => {
@ -295,8 +303,10 @@ export const updateLevels = (
levels[index] = update; levels[index] = update;
} }
} else if (update.volume !== '0') { } else if (update.volume !== '0') {
index = levels.findIndex( index = levels.findIndex((level) =>
(level) => BigInt(level.price) > BigInt(update.price) ascending
? BigInt(level.price) > BigInt(update.price)
: BigInt(level.price) < BigInt(update.price)
); );
if (index !== -1) { if (index !== -1) {
levels.splice(index, 0, update); levels.splice(index, 0, update);
@ -346,22 +356,20 @@ export const generateMockData = ({
numberOfOrders: '', numberOfOrders: '',
})); }));
const rows = compactRows(sell, buy, resolution); const rows = compactRows(sell, buy, resolution);
const marketTradingMode =
overlap > 0
? Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION
: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS;
return { return {
rows, rows,
resolution, resolution,
indicativeVolume: indicativeVolume?.toString(), indicativeVolume: indicativeVolume?.toString(),
marketTradingMode: marketTradingMode,
overlap > 0 midPrice: ((bestStaticBidPrice + bestStaticOfferPrice) / 2).toString(),
? Schema.MarketTradingMode.TRADING_MODE_BATCH_AUCTION bestStaticBidPrice: bestStaticBidPrice.toString(),
: Schema.MarketTradingMode.TRADING_MODE_CONTINUOUS, bestStaticOfferPrice: bestStaticOfferPrice.toString(),
...mapMarketData( indicativePrice: indicativePrice
{ ? getPriceLevel(indicativePrice.toString(), resolution)
staticMidPrice: '', : undefined,
bestStaticBidPrice: bestStaticBidPrice.toString(),
bestStaticOfferPrice: bestStaticOfferPrice.toString(),
indicativePrice: indicativePrice?.toString() ?? '',
},
resolution
),
}; };
}; };

View File

@ -1,3 +1,4 @@
import React from 'react';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { Orderbook } from './orderbook'; import { Orderbook } from './orderbook';
@ -8,12 +9,14 @@ import type { MarketData } from '@vegaprotocol/market-list';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { import type {
MarketDepthUpdateSubscription, MarketDepthUpdateSubscription,
MarketDepthQuery,
PriceLevelFieldsFragment, PriceLevelFieldsFragment,
} from './__generated__/MarketDepth'; } from './__generated__/MarketDepth';
import { import {
compactRows, compactRows,
updateCompactedRows, updateCompactedRows,
mapMarketData, getMidPrice,
getPriceLevel,
} from './orderbook-data'; } from './orderbook-data';
import type { OrderbookData } from './orderbook-data'; import type { OrderbookData } from './orderbook-data';
import { usePersistedOrderStore } from '@vegaprotocol/orders'; import { usePersistedOrderStore } from '@vegaprotocol/orders';
@ -31,6 +34,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
}); });
const dataRef = useRef<OrderbookData>({ rows: null }); const dataRef = useRef<OrderbookData>({ rows: null });
const marketDataRef = useRef<MarketData | null>(null); const marketDataRef = useRef<MarketData | null>(null);
const rawDataRef = useRef<MarketDepthQuery['market'] | null>(null);
const deltaRef = useRef<{ const deltaRef = useRef<{
sell: PriceLevelFieldsFragment[]; sell: PriceLevelFieldsFragment[];
buy: PriceLevelFieldsFragment[]; buy: PriceLevelFieldsFragment[];
@ -42,7 +46,17 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
throttle(() => { throttle(() => {
dataRef.current = { dataRef.current = {
...marketDataRef.current, ...marketDataRef.current,
...mapMarketData(marketDataRef.current, resolutionRef.current), indicativePrice: marketDataRef.current?.indicativePrice
? getPriceLevel(
marketDataRef.current.indicativePrice,
resolutionRef.current
)
: undefined,
midPrice: getMidPrice(
rawDataRef.current?.depth.sell,
rawDataRef.current?.depth.buy,
resolution
),
rows: rows:
deltaRef.current.buy.length || deltaRef.current.sell.length deltaRef.current.buy.length || deltaRef.current.sell.length
? updateCompactedRows( ? updateCompactedRows(
@ -56,14 +70,16 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
deltaRef.current.buy = []; deltaRef.current.buy = [];
deltaRef.current.sell = []; deltaRef.current.sell = [];
setOrderbookData(dataRef.current); setOrderbookData(dataRef.current);
}, 1000) }, 250)
); );
const update = useCallback( const update = useCallback(
({ ({
delta: deltas, delta: deltas,
data: rawData,
}: { }: {
delta?: MarketDepthUpdateSubscription['marketsDepthUpdate']; delta?: MarketDepthUpdateSubscription['marketsDepthUpdate'];
data?: MarketDepthQuery['market'];
}) => { }) => {
if (!dataRef.current.rows) { if (!dataRef.current.rows) {
return false; return false;
@ -78,6 +94,7 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
if (delta.buy) { if (delta.buy) {
deltaRef.current.buy.push(...delta.buy); deltaRef.current.buy.push(...delta.buy);
} }
rawDataRef.current = rawData;
updateOrderbookData.current(); updateOrderbookData.current();
} }
return true; return true;
@ -134,9 +151,14 @@ export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
} }
dataRef.current = { dataRef.current = {
...marketDataRef.current, ...marketDataRef.current,
...mapMarketData(marketDataRef.current, resolution), indicativePrice: getPriceLevel(
marketDataRef.current.indicativePrice,
resolution
),
midPrice: getMidPrice(data.depth.sell, data.depth.buy, resolution),
rows: compactRows(data.depth.sell, data.depth.buy, resolution), rows: compactRows(data.depth.sell, data.depth.buy, resolution),
}; };
rawDataRef.current = data;
setOrderbookData(dataRef.current); setOrderbookData(dataRef.current);
return () => { return () => {

View File

@ -92,7 +92,7 @@ describe('Orderbook', () => {
expect(result.getByTestId('scroll').scrollTop).toBe(90 * rowHeight); expect(result.getByTestId('scroll').scrollTop).toBe(90 * rowHeight);
}); });
it('should should keep price it the middle', async () => { it('should keep price it the middle', async () => {
window.innerHeight = 11 * rowHeight; window.innerHeight = 11 * rowHeight;
const result = render( const result = render(
<Orderbook <Orderbook
@ -106,7 +106,7 @@ describe('Orderbook', () => {
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`)); await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
const scrollElement = result.getByTestId('scroll'); const scrollElement = result.getByTestId('scroll');
expect(scrollElement.scrollTop).toBe(91 * rowHeight); expect(scrollElement.scrollTop).toBe(91 * rowHeight);
scrollElement.scrollTop = 92 * rowHeight; scrollElement.scrollTop = 92 * rowHeight + 0.01;
fireEvent.scroll(scrollElement); fireEvent.scroll(scrollElement);
result.rerender( result.rerender(
<Orderbook <Orderbook
@ -121,10 +121,10 @@ describe('Orderbook', () => {
/> />
); );
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`)); await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight); expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight + 0.01);
}); });
it('should should get back to mid price on click', async () => { it('should get back to mid price on click', async () => {
window.innerHeight = 11 * rowHeight; window.innerHeight = 11 * rowHeight;
const result = render( const result = render(
<Orderbook <Orderbook
@ -138,15 +138,15 @@ describe('Orderbook', () => {
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`)); await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
const scrollElement = result.getByTestId('scroll'); const scrollElement = result.getByTestId('scroll');
expect(scrollElement.scrollTop).toBe(91 * rowHeight); expect(scrollElement.scrollTop).toBe(91 * rowHeight);
scrollElement.scrollTop = 0; scrollElement.scrollTop = 1;
fireEvent.scroll(scrollElement); fireEvent.scroll(scrollElement);
expect(result.getByTestId('scroll').scrollTop).toBe(0); expect(result.getByTestId('scroll').scrollTop).toBe(1);
const scrollToMidPriceButton = result.getByTestId('scroll-to-midprice'); const scrollToMidPriceButton = result.getByTestId('scroll-to-midprice');
fireEvent.click(scrollToMidPriceButton); fireEvent.click(scrollToMidPriceButton);
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight); expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight + 1);
}); });
it('should should get back to mid price on resolution change', async () => { it('should get back to mid price on resolution change', async () => {
window.innerHeight = 11 * rowHeight; window.innerHeight = 11 * rowHeight;
const result = render( const result = render(
<Orderbook <Orderbook
@ -160,9 +160,9 @@ describe('Orderbook', () => {
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`)); await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
const scrollElement = result.getByTestId('scroll'); const scrollElement = result.getByTestId('scroll');
expect(scrollElement.scrollTop).toBe(91 * rowHeight); expect(scrollElement.scrollTop).toBe(91 * rowHeight);
scrollElement.scrollTop = 0; scrollElement.scrollTop = 1;
fireEvent.scroll(scrollElement); fireEvent.scroll(scrollElement);
expect(result.getByTestId('scroll').scrollTop).toBe(0); expect(result.getByTestId('scroll').scrollTop).toBe(1);
const resolutionSelect = result.getByTestId( const resolutionSelect = result.getByTestId(
'resolution' 'resolution'
) as HTMLSelectElement; ) as HTMLSelectElement;
@ -181,6 +181,6 @@ describe('Orderbook', () => {
onResolutionChange={onResolutionChange} onResolutionChange={onResolutionChange}
/> />
); );
expect(result.getByTestId('scroll').scrollTop).toBe(5 * rowHeight); expect(result.getByTestId('scroll').scrollTop).toBe(6 * rowHeight);
}); });
}); });

View File

@ -1,15 +1,7 @@
import styles from './orderbook.module.scss'; import styles from './orderbook.module.scss';
import colors from 'tailwindcss/colors'; import colors from 'tailwindcss/colors';
import { import { useEffect, useRef, useState, useCallback, Fragment } from 'react';
useEffect,
useLayoutEffect,
useRef,
useState,
useMemo,
useCallback,
Fragment,
} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
@ -21,7 +13,7 @@ import {
} from '@vegaprotocol/react-helpers'; } from '@vegaprotocol/react-helpers';
import * as Schema from '@vegaprotocol/types'; import * as Schema from '@vegaprotocol/types';
import { OrderbookRow } from './orderbook-row'; import { OrderbookRow } from './orderbook-row';
import { createRow, getPriceLevel } from './orderbook-data'; import { createRow } from './orderbook-data';
import { Checkbox, Icon, Splash } from '@vegaprotocol/ui-toolkit'; import { Checkbox, Icon, Splash } from '@vegaprotocol/ui-toolkit';
import type { OrderbookData, OrderbookRowData } from './orderbook-data'; import type { OrderbookData, OrderbookRowData } from './orderbook-data';
@ -36,7 +28,7 @@ interface OrderbookProps extends OrderbookData {
const HorizontalLine = ({ top, testId }: { top: string; testId: string }) => ( const HorizontalLine = ({ top, testId }: { top: string; testId: string }) => (
<div <div
className="absolute border-b border-default inset-x-0" className="absolute border-b border-default inset-x-0 hidden"
style={{ top }} style={{ top }}
data-testid={testId} data-testid={testId}
/> />
@ -97,7 +89,12 @@ const getRowsToRender = (
}; };
// 17px of row height plus 5px gap // 17px of row height plus 5px gap
export const gridGap = 5;
export const rowHeight = 22; export const rowHeight = 22;
// top padding to make space for header
const headerPadding = 30;
// bottom padding to make space for footer
const footerPadding = 25;
// buffer size in rows // buffer size in rows
const bufferSize = 30; const bufferSize = 30;
// margin size in px, when reached scrollOffset will be updated // margin size in px, when reached scrollOffset will be updated
@ -112,30 +109,30 @@ const getBestStaticBidPriceLinePosition = (
rows: OrderbookRowData[] | null rows: OrderbookRowData[] | null
) => { ) => {
let bestStaticBidPriceLinePosition = ''; let bestStaticBidPriceLinePosition = '';
if (maxPriceLevel !== '0' && minPriceLevel !== '0') { if (
if ( rows?.length &&
bestStaticBidPrice && bestStaticBidPrice &&
BigInt(bestStaticBidPrice) < BigInt(maxPriceLevel) && BigInt(bestStaticBidPrice) < BigInt(maxPriceLevel) &&
BigInt(bestStaticBidPrice) > BigInt(minPriceLevel) BigInt(bestStaticBidPrice) > BigInt(minPriceLevel)
) { ) {
if (fillGaps) { if (fillGaps) {
bestStaticBidPriceLinePosition = (
((BigInt(maxPriceLevel) - BigInt(bestStaticBidPrice)) /
BigInt(resolution)) *
BigInt(rowHeight) +
BigInt(headerPadding) -
BigInt(3)
).toString();
} else {
const index = rows?.findIndex(
(row) => BigInt(row.price) <= BigInt(bestStaticBidPrice)
);
if (index !== undefined && index !== -1) {
bestStaticBidPriceLinePosition = ( bestStaticBidPriceLinePosition = (
((BigInt(maxPriceLevel) - BigInt(bestStaticBidPrice)) / index * rowHeight +
BigInt(resolution) + headerPadding -
BigInt(1)) * 3
BigInt(rowHeight) +
BigInt(1)
).toString(); ).toString();
} else {
const index = rows?.findIndex(
(row) => BigInt(row.price) <= BigInt(bestStaticBidPrice)
);
if (index !== undefined && index !== -1) {
bestStaticBidPriceLinePosition = (
(index + 1) * rowHeight +
1
).toString();
}
} }
} }
} }
@ -151,6 +148,7 @@ const getBestStaticOfferPriceLinePosition = (
) => { ) => {
let bestStaticOfferPriceLinePosition = ''; let bestStaticOfferPriceLinePosition = '';
if ( if (
rows?.length &&
bestStaticOfferPrice && bestStaticOfferPrice &&
BigInt(bestStaticOfferPrice) <= BigInt(maxPriceLevel) && BigInt(bestStaticOfferPrice) <= BigInt(maxPriceLevel) &&
BigInt(bestStaticOfferPrice) > BigInt(minPriceLevel) BigInt(bestStaticOfferPrice) > BigInt(minPriceLevel)
@ -159,9 +157,10 @@ const getBestStaticOfferPriceLinePosition = (
bestStaticOfferPriceLinePosition = ( bestStaticOfferPriceLinePosition = (
((BigInt(maxPriceLevel) - BigInt(bestStaticOfferPrice)) / ((BigInt(maxPriceLevel) - BigInt(bestStaticOfferPrice)) /
BigInt(resolution) + BigInt(resolution) +
BigInt(2)) * BigInt(1)) *
BigInt(rowHeight) + BigInt(rowHeight) +
BigInt(1) BigInt(headerPadding) -
BigInt(3)
).toString(); ).toString();
} else { } else {
const index = rows?.findIndex( const index = rows?.findIndex(
@ -169,8 +168,9 @@ const getBestStaticOfferPriceLinePosition = (
); );
if (index !== undefined && index !== -1) { if (index !== undefined && index !== -1) {
bestStaticOfferPriceLinePosition = ( bestStaticOfferPriceLinePosition = (
(index + 2) * rowHeight + (index + 1) * rowHeight +
1 headerPadding -
3
).toString(); ).toString();
} }
} }
@ -187,7 +187,7 @@ const OrderbookDebugInfo = ({
bestStaticOfferPrice, bestStaticOfferPrice,
maxPriceLevel, maxPriceLevel,
minPriceLevel, minPriceLevel,
resolution, midPrice,
}: { }: {
decimalPlaces: number; decimalPlaces: number;
numberOfRows: number; numberOfRows: number;
@ -198,7 +198,7 @@ const OrderbookDebugInfo = ({
bestStaticOfferPrice?: string; bestStaticOfferPrice?: string;
maxPriceLevel: string; maxPriceLevel: string;
minPriceLevel: string; minPriceLevel: string;
resolution: number; midPrice?: string;
}) => ( }) => (
<Fragment> <Fragment>
<div <div
@ -247,16 +247,7 @@ const OrderbookDebugInfo = ({
decimalPlaces decimalPlaces
), ),
midPrice: addDecimalsFixedFormatNumber( midPrice: addDecimalsFixedFormatNumber(
(bestStaticOfferPrice && midPrice ?? '0',
bestStaticBidPrice &&
getPriceLevel(
BigInt(bestStaticOfferPrice) +
(BigInt(bestStaticBidPrice) -
BigInt(bestStaticOfferPrice)) /
BigInt(2),
resolution
)) ??
'0',
decimalPlaces decimalPlaces
), ),
}, },
@ -270,6 +261,7 @@ const OrderbookDebugInfo = ({
export const Orderbook = ({ export const Orderbook = ({
rows, rows,
midPrice,
bestStaticBidPrice, bestStaticBidPrice,
bestStaticOfferPrice, bestStaticOfferPrice,
marketTradingMode, marketTradingMode,
@ -295,21 +287,36 @@ export const Orderbook = ({
// price level which is rendered in center of viewport, need to preserve price level when rows will be added or removed // price level which is rendered in center of viewport, need to preserve price level when rows will be added or removed
// if undefined then we render mid price in center // if undefined then we render mid price in center
const priceInCenter = useRef<string>(); const priceInCenter = useRef<string>();
// by default mid price is rendered in center - view locked on mid price
const [lockOnMidPrice, setLockOnMidPrice] = useState(true); const [lockOnMidPrice, setLockOnMidPrice] = useState(true);
const resolutionRef = useRef(resolution); const resolutionRef = useRef(resolution);
const [viewportHeight, setViewportHeight] = useState(window.innerHeight); const [viewportHeight, setViewportHeight] = useState(window.innerHeight);
// show price levels with no orders, can lead to enormous number of rows
const [fillGaps, setFillGaps] = useState(!!initialFillGaps); const [fillGaps, setFillGaps] = useState(!!initialFillGaps);
const numberOfRows = useMemo(
() => (fillGaps ? getNumberOfRows(rows, resolution) : rows?.length ?? 0),
[rows, resolution, fillGaps]
);
const maxPriceLevel = rows?.[0]?.price ?? '0';
const minPriceLevel = (
fillGaps
? BigInt(maxPriceLevel) - BigInt(Math.floor(numberOfRows * resolution))
: BigInt(rows?.[rows.length - 1]?.price ?? '0')
).toString();
const [debug, setDebug] = useState(false); const [debug, setDebug] = useState(false);
const numberOfRows = fillGaps
? getNumberOfRows(rows, resolution)
: rows?.length ?? 0;
const maxPriceLevel = rows?.[0]?.price ?? '0';
const minPriceLevel = rows?.[rows.length - 1]?.price ?? '0';
let offset = Math.max(0, Math.round(scrollOffset / rowHeight));
const prependingBufferSize = Math.min(bufferSize, offset);
offset -= prependingBufferSize;
const viewportSize = Math.round(viewportHeight / rowHeight);
const limit = Math.min(
prependingBufferSize + viewportSize + bufferSize,
numberOfRows - offset
);
const data = fillGaps
? getRowsToRender(rows, resolution, offset, limit)
: rows?.slice(offset, offset + limit) ?? [];
const paddingTop = offset * rowHeight + headerPadding;
const paddingBottom =
(numberOfRows - offset - limit) * rowHeight + footerPadding;
const updateScrollOffset = useCallback( const updateScrollOffset = useCallback(
(scrollTop: number) => { (scrollTop: number) => {
if (Math.abs(scrollOffset - scrollTop) > marginSize) { if (Math.abs(scrollOffset - scrollTop) > marginSize) {
@ -318,23 +325,35 @@ export const Orderbook = ({
}, },
[scrollOffset] [scrollOffset]
); );
const onScroll = useCallback( const onScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => { (event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget; const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
updateScrollOffset(scrollTop); updateScrollOffset(scrollTop);
if (scrollTop === scrollTopRef.current) { if (scrollTop === scrollTopRef.current) {
return; return;
} else if ((scrollTop - scrollTopRef.current) % rowHeight === 0) {
if (scrollElement.current) {
scrollElement.current.scrollTop = scrollTopRef.current;
}
return;
}
if (scrollTop === 0 || scrollHeight === clientHeight + scrollTop) {
priceInCenter.current = undefined;
} else {
// top offset in rows to row in the middle
const offsetTop = Math.floor(
(scrollTop +
Math.floor((viewportHeight - footerPadding - headerPadding) / 2)) /
rowHeight
);
priceInCenter.current = fillGaps
? (
BigInt(maxPriceLevel) -
BigInt(offsetTop) * BigInt(resolution)
).toString()
: rows?.[Math.min(offsetTop, rows.length - 1)].price.toString();
} }
const offsetTop = Math.floor(
(scrollTop + Math.floor(viewportHeight / 2)) / rowHeight
);
priceInCenter.current = fillGaps
? (
BigInt(resolution) + // extra row on very top - sticky header
BigInt(maxPriceLevel) -
BigInt(offsetTop) * BigInt(resolution)
).toString()
: rows?.[Math.min(offsetTop, rows.length - 1)]?.price?.toString();
if (lockOnMidPrice) { if (lockOnMidPrice) {
setLockOnMidPrice(false); setLockOnMidPrice(false);
} }
@ -361,24 +380,22 @@ export const Orderbook = ({
(Number( (Number(
(BigInt(maxPriceLevel) - BigInt(price)) / BigInt(resolution) (BigInt(maxPriceLevel) - BigInt(price)) / BigInt(resolution)
) + ) +
1) * // add one row for sticky header 1) *
rowHeight + rowHeight;
rowHeight / 2 -
(viewportHeight % rowHeight);
} else if (rows) { } else if (rows) {
const index = rows.findIndex( const index = rows.findIndex(
(row) => BigInt(row.price) <= BigInt(price) (row) => BigInt(row.price) <= BigInt(price)
); );
if (index !== -1) { if (index !== -1) {
scrollTop = scrollTop = rowHeight * (index + 1);
index * rowHeight + rowHeight / 2 - (viewportHeight % rowHeight); if (index !== 0) {
if ( const diffToCurrentRow =
price === rows[index].price || BigInt(price) - BigInt(rows[index].price);
index === 0 || const diffToPreviousRow =
BigInt(rows[index].price) - BigInt(price) < BigInt(rows[index - 1].price) - BigInt(price);
BigInt(price) - BigInt(rows[index - 1].price) if (diffToPreviousRow < diffToCurrentRow) {
) { scrollTop -= rowHeight;
scrollTop += rowHeight; }
} }
} }
} }
@ -389,7 +406,13 @@ export const Orderbook = ({
(scrollTopRef.current % rowHeight) - (scrollTop % rowHeight); (scrollTopRef.current % rowHeight) - (scrollTop % rowHeight);
const priceCenterScrollOffset = Math.max( const priceCenterScrollOffset = Math.max(
0, 0,
Math.min(scrollTop, numberOfRows * rowHeight - viewportHeight) Math.min(
scrollTop,
numberOfRows * rowHeight +
headerPadding +
footerPadding +
-viewportHeight
)
); );
if (scrollTopRef.current !== priceCenterScrollOffset) { if (scrollTopRef.current !== priceCenterScrollOffset) {
updateScrollOffset(priceCenterScrollOffset); updateScrollOffset(priceCenterScrollOffset);
@ -410,72 +433,31 @@ export const Orderbook = ({
); );
const scrollToMidPrice = useCallback(() => { const scrollToMidPrice = useCallback(() => {
if (!bestStaticOfferPrice || !bestStaticBidPrice) { if (!midPrice) {
return; return;
} }
priceInCenter.current = undefined; priceInCenter.current = undefined;
let midPrice = getPriceLevel(
BigInt(bestStaticOfferPrice) +
(BigInt(bestStaticBidPrice) - BigInt(bestStaticOfferPrice)) / BigInt(2),
resolution
);
if (BigInt(midPrice) > BigInt(maxPriceLevel)) {
midPrice = maxPriceLevel;
} else {
if (BigInt(midPrice) < BigInt(minPriceLevel)) {
midPrice = minPriceLevel.toString();
}
}
scrollToPrice(midPrice); scrollToPrice(midPrice);
setLockOnMidPrice(true); setLockOnMidPrice(true);
}, [ }, [midPrice, scrollToPrice]);
bestStaticOfferPrice,
bestStaticBidPrice,
scrollToPrice,
resolution,
maxPriceLevel,
minPriceLevel,
]);
// adjust scroll position to keep selected price in center // adjust scroll position to keep selected price in center
useLayoutEffect(() => { useEffect(() => {
if (priceInCenter.current) {
scrollToPrice(priceInCenter.current);
} else if (lockOnMidPrice && midPrice) {
scrollToPrice(midPrice);
}
}, [midPrice, scrollToPrice, lockOnMidPrice]);
useEffect(() => {
if (resolutionRef.current !== resolution) { if (resolutionRef.current !== resolution) {
priceInCenter.current = undefined; priceInCenter.current = undefined;
resolutionRef.current = resolution; resolutionRef.current = resolution;
setLockOnMidPrice(true);
} }
if (priceInCenter.current) { }, [resolution]);
scrollToPrice(priceInCenter.current);
} else {
scrollToMidPrice();
}
}, [scrollToMidPrice, scrollToPrice, resolution]);
// handles window resize
useEffect(() => {
function handleResize() {
if (rootElement.current) {
setViewportHeight(
rootElement.current.clientHeight || window.innerHeight
);
}
}
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, []);
// sets the correct width of header and footer
useLayoutEffect(() => {
if (
!gridElement.current ||
!headerElement.current ||
!footerElement.current
) {
return;
}
const gridWidth = gridElement.current.clientWidth;
headerElement.current.style.width = `${gridWidth}px`;
footerElement.current.style.width = `${gridWidth}px`;
}, [headerElement, footerElement, gridElement]);
// handles resizing of the Allotment.Pane (x-axis) // handles resizing of the Allotment.Pane (x-axis)
// adjusts the header and footer width // adjusts the header and footer width
const gridResizeHandler: ResizeObserverCallback = useCallback( const gridResizeHandler: ResizeObserverCallback = useCallback(
@ -509,20 +491,6 @@ export const Orderbook = ({
useResizeObserver(gridElement.current, gridResizeHandler); useResizeObserver(gridElement.current, gridResizeHandler);
useResizeObserver(rootElement.current, rootElementResizeHandler); useResizeObserver(rootElement.current, rootElementResizeHandler);
let offset = Math.max(0, Math.round(scrollOffset / rowHeight));
const prependingBufferSize = Math.min(bufferSize, offset);
offset -= prependingBufferSize;
const viewportSize = Math.round(viewportHeight / rowHeight);
const limit = Math.min(
prependingBufferSize + viewportSize + bufferSize,
numberOfRows - offset
);
const data = fillGaps
? getRowsToRender(rows, resolution, offset, limit)
: rows?.slice(offset, offset + limit) ?? [];
const paddingTop = offset * rowHeight;
const paddingBottom = (numberOfRows - offset - limit) * rowHeight;
const tableBody = const tableBody =
data && data.length !== 0 ? ( data && data.length !== 0 ? (
<div <div
@ -603,16 +571,16 @@ export const Orderbook = ({
</div> </div>
</div> </div>
<div <div
className={`h-full overflow-auto relative ${styles['scroll']} pt-[26px] pb-[17px]`} className={`h-full overflow-auto relative ${styles['scroll']}`}
onScroll={onScroll} onScroll={onScroll}
ref={scrollElement} ref={scrollElement}
data-testid="scroll" data-testid="scroll"
> >
<div <div
className="relative text-right min-h-full" className="relative text-right min-h-full overflow-hidden"
style={{ style={{
paddingTop: paddingTop, paddingTop,
paddingBottom: paddingBottom, paddingBottom,
background: tableBody ? gradientStyles : 'none', background: tableBody ? gradientStyles : 'none',
}} }}
ref={gridElement} ref={gridElement}
@ -685,7 +653,7 @@ export const Orderbook = ({
{debug && ( {debug && (
<OrderbookDebugInfo <OrderbookDebugInfo
decimalPlaces={decimalPlaces} decimalPlaces={decimalPlaces}
resolution={resolution} midPrice={midPrice}
numberOfRows={numberOfRows} numberOfRows={numberOfRows}
viewportHeight={viewportHeight} viewportHeight={viewportHeight}
lockOnMidPrice={lockOnMidPrice} lockOnMidPrice={lockOnMidPrice}