Feature/303 orderbook improvements (#312)

* [#151] market-depth code cleanup

* [#303] Make ask and bid relative volume bars relative to maximum bid or ask volume

* [#151] align cmulative vol bars to left

* [#151] replace orderbook zoom in zoom out buttons with dropdown

* [#151] fill gaps in orderbook data

* Order book mocks added

* [#151] mark mid price in orderbook

* [303] Show number in orderbook cumulative volume column

* [#808] show indicative uncrossing volume instead of volume if market is in auction mode

* Method for asserting order book style

* [#303] Add test id attributes to orderbook cells

* Cleanup steps after merge

* Order book test passing

* Change method name

* Revert "[#151] fill gaps in orderbook data"

This reverts commit 90ea4e4ab3.

* [#303] Orderbook rows render optimization

* test: update feature with @todo tests

Same tests can be found in Notion

* [#303] Orderbook scroll to mid price

* [#303] orderbook scroll to row pixel perfect alignment

* [#303] Bring back best offer horizontal lines

* [#303] Preserve center price level on row number change, adjust indicativePrice to resoluton

* feat(orderbook): add storybook

Refs: #303

* feat(orderbook): fix no rows handling

Refs: #303

* feat(orderbook): add orderbook stories for auction and continous market

Refs: #303

* feat(orderbook): add stories for empty orderbook

Refs: #303

* feat(orderbook): fix footer position when there is no data

Refs: #303

* feat(orderbook): seperate number of rows for buy and sell in storybook

Refs: #303

* feat(orderbook): keep mid price in middle until user will scroll

Refs: #303

* feat(orderbook): style scrollbar

* feat(orderbook): style scrollbar

* feat(orderbook): adjust gaps

* feat(orderbook): adjust gaps

* test: addition for autofilled order and mid price lines

* fix: lint

* feat(orderbook): make it posiible to write RTL tests

* feat(orderbook): fix price focus, add unit tests

* feat(orderbook): fix price scroll to mid proce, add unit tests

* feat(orderbook): improvements

- fix scrollbar colors in firefox
- bring back resolution dropdown chevron
- hide go to mid button when locked on mid price
- right align ask vol bar
- change grid gap to 5px
- add vertical lines between columns
- display "No data" if theis no orderbook data
- align header labels to right

* feat(orderbook): fix formatting

* feat(orderbook): add 5px gap

* feat(orderbook): improvements after code review

* feat(orderbook): display full height vertical lines

* fix: change in mid position

* feat(orderbook): fix number cannot be converted to BigInt because it is not integer

* feat(orderbook): fix TS2307 in trading-e2e caused by .module.scss import

Co-authored-by: Joe <joe@vega.xyz>
This commit is contained in:
Bartłomiej Głownia 2022-06-10 15:52:39 +02:00 committed by GitHub
parent 71226e3d75
commit d0452aeb81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 2158 additions and 388 deletions

1
apps/trading-e2e/declaration.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.scss';

View File

@ -76,15 +76,6 @@ Feature: Trading page
And place a buy 'FOK' market order with amount of 0
Then Order rejected by wallet error shown containing text "must be positive"
@manual
Scenario: Deal ticket: GTT order failed because invalid date
@manual
Scenario: Deal ticket: GTT order failed because date in the past
@manual
Scenario: Deal ticket: GTT order failed because date over allowed period
Scenario: Positions: Displayed when connected to wallet
Given I am on the trading page for an active market
And I connect to Vega Wallet
@ -97,9 +88,40 @@ Feature: Trading page
When I click on accounts tab
Then accounts are displayed
And I can see account for tEURO
Scenario: Orders: Placed orders displayed
Given I am on the trading page for an active market
And I connect to Vega Wallet
When I click on orders tab
Then placed orders are displayed
Then placed orders are displayed
Scenario: Orderbook displayed
Given I am on the trading page for an active market
When I click on order book tab
Then orderbook is displayed with expected orders
And orderbook can be reduced and expanded
@todo
Scenario: Orderbook paginated with over 100 orders
Given I am on the trading page for an active market
When I click on order book tab
And a large amount is orders are received
Then a certain amount of orders are displayed
@todo
Scenario: Orderbook uses non-static prices for market in auction
Given I am on the trading page for a market in auction
When I click on order book tab
Then order book is rendered using non-static offers
@todo
Scenario: Orderbook updated when large order is made
Given I am on the trading page for an active market
When I place a large order
Then I should see my order have an effect on the order book
@todo
Scenario: Able to place order by clicking on order from orderbook
Given I am on the trading page for an active market
When I place a large order
Then I should see my order have an effect on the order book

View File

@ -0,0 +1,125 @@
import merge from 'lodash/merge';
import type { PartialDeep } from 'type-fest';
import type {
MarketDepth,
MarketDepth_market,
} from '@vegaprotocol/market-depth';
import { MarketTradingMode } from '@vegaprotocol/types';
export const generateOrderBook = (
override?: PartialDeep<MarketDepth>
): MarketDepth => {
const marketDepth: MarketDepth_market = {
id: 'b2426f67b085ba8fb429f1b529d49372b2d096c6fb6f509f76c5863abb6d969e',
decimalPlaces: 5,
data: {
staticMidPrice: '826337',
marketTradingMode: MarketTradingMode.Continuous,
indicativeVolume: '0',
indicativePrice: '0',
bestStaticBidPrice: '826336',
bestStaticOfferPrice: '826338',
market: {
id: 'b2426f67b085ba8fb429f1b529d49372b2d096c6fb6f509f76c5863abb6d969e',
__typename: 'Market',
},
__typename: 'MarketData',
},
depth: {
lastTrade: {
price: '826338',
__typename: 'Trade',
},
sell: [
{
price: '826338',
volume: '303',
numberOfOrders: '8',
__typename: 'PriceLevel',
},
{
price: '826339',
volume: '193',
numberOfOrders: '4',
__typename: 'PriceLevel',
},
{
price: '826340',
volume: '316',
numberOfOrders: '7',
__typename: 'PriceLevel',
},
{
price: '826341',
volume: '412',
numberOfOrders: '9',
__typename: 'PriceLevel',
},
{
price: '826342',
volume: '264',
numberOfOrders: '6',
__typename: 'PriceLevel',
},
],
buy: [
{
price: '826339',
volume: '200',
numberOfOrders: '5',
__typename: 'PriceLevel',
},
{
price: '826336',
volume: '1475',
numberOfOrders: '28',
__typename: 'PriceLevel',
},
{
price: '826335',
volume: '193',
numberOfOrders: '3',
__typename: 'PriceLevel',
},
{
price: '826334',
volume: '425',
numberOfOrders: '8',
__typename: 'PriceLevel',
},
{
price: '826333',
volume: '845',
numberOfOrders: '17',
__typename: 'PriceLevel',
},
{
price: '826332',
volume: '248',
numberOfOrders: '4',
__typename: 'PriceLevel',
},
{
price: '826331',
volume: '162',
numberOfOrders: '3',
__typename: 'PriceLevel',
},
{
price: '826328',
volume: '20',
numberOfOrders: '2',
__typename: 'PriceLevel',
},
],
sequenceNumber: '36109974',
__typename: 'MarketDepth',
},
__typename: 'Market',
};
const defaultResult = {
market: marketDepth,
};
return merge(defaultResult, override);
};

View File

@ -10,7 +10,6 @@ export default class TradingPage extends BasePage {
collateralTab = 'Collateral';
tradesTab = 'Trades';
completedTrades = 'Market-trades';
orderBookTab = 'Prderbook';
clickOnOrdersTab() {
cy.getByTestId(this.ordersTab).click();
@ -37,6 +36,6 @@ export default class TradingPage extends BasePage {
}
clickOrderBookTab() {
cy.getByTestId(this.orderBookTab).click();
cy.getByTestId(this.orderbookTab).click();
}
}

View File

@ -8,20 +8,23 @@ import { generateDealTicketQuery } from '../mocks/generate-deal-ticket-query';
import { generateMarket } from '../mocks/generate-market';
import { generateOrders } from '../mocks/generate-orders';
import { generatePositions } from '../mocks/generate-positions';
import { generateOrderBook } from '../mocks/generate-order-book';
import { generateAccounts } from '../mocks/generate-accounts';
import PositionsList from '../trading-windows/positions-list';
import AccountsList from '../trading-windows/accounts-list';
import TradesList from '../trading-windows/trades-list';
import TradingPage from '../pages/trading-page';
import OrdersList from '../trading-windows/orders-list';
import OrderBookList from '../trading-windows/orderbook-list';
import MarketPage from '../pages/markets-page';
const tradesList = new TradesList();
const tradingPage = new TradingPage();
const marketPage = new MarketPage();
const positionsList = new PositionsList();
const accountList = new AccountsList();
const ordersList = new OrdersList();
const orderBookList = new OrderBookList();
const marketPage = new MarketPage();
const mockMarket = (state: MarketState) => {
cy.mockGQL('Market', (req) => {
@ -75,6 +78,12 @@ const mockMarket = (state: MarketState) => {
});
}
if (hasOperationName(req, 'MarketDepth')) {
req.reply({
body: { data: generateOrderBook() },
});
}
if (hasOperationName(req, 'Candles')) {
req.reply({
body: { data: generateCandles() },
@ -159,3 +168,78 @@ When('I click on positions tab', () => {
Then('positions are displayed', () => {
positionsList.verifyPositionsDisplayed();
});
When('I click on order book tab', () => {
tradingPage.clickOrderBookTab();
});
Then('orderbook is displayed with expected orders', () => {
orderBookList.verifyOrderBookRow('826342', '0', '8.26342', '264', '1488');
orderBookList.verifyOrderBookRow('826336', '1475', '8.26336', '0', '1675');
orderBookList.verifyDisplayedVolume(
'826342',
false,
'18%',
orderBookList.testingVolume.AskVolume
);
orderBookList.verifyDisplayedVolume(
'826331',
true,
'100%',
orderBookList.testingVolume.CumulativeVolume
);
// mid level price
orderBookList.verifyOrderBookRow('826337', '0', '8.26337', '0', '200');
orderBookList.verifyDisplayedVolume(
'826337',
true,
'6%',
orderBookList.testingVolume.CumulativeVolume
);
orderBookList.verifyTopMidPricePosition('129');
orderBookList.verifyBottomMidPricePosition('151');
// autofilled order
orderBookList.verifyOrderBookRow('826330', '0', '8.26330', '0', '3548');
orderBookList.verifyDisplayedVolume(
'826330',
true,
'0%',
orderBookList.testingVolume.BidVolume
);
orderBookList.verifyDisplayedVolume(
'826330',
true,
'100%',
orderBookList.testingVolume.CumulativeVolume
);
});
Then('orderbook can be reduced and expanded', () => {
orderBookList.changePrecision('10');
orderBookList.verifyOrderBookRow(
'82634',
'1868',
'8.2634',
'1488',
'1488/1868'
);
orderBookList.verifyCumulativeAskBarPercentage('42%');
orderBookList.verifyCumulativeBidBarPercentage('53%');
orderBookList.changePrecision('100');
orderBookList.verifyOrderBookRow('8263', '3568', '8.263', '1488', '');
orderBookList.verifyDisplayedVolume(
'8263',
true,
'100%',
orderBookList.testingVolume.BidVolume
);
orderBookList.verifyDisplayedVolume(
'8263',
false,
'42%',
orderBookList.testingVolume.AskVolume
);
orderBookList.changePrecision('1');
orderBookList.verifyOrderBookRow('826342', '0', '8.26342', '264', '1488');
});

View File

@ -0,0 +1,129 @@
export default class OrderBookList {
cumulativeVolBidBar = 'bid-bar';
cumulativeVolAskBar = 'ask-bar';
precisionChange = 'resolution';
bidColour = 'darkgreen';
askColour = 'maroon';
testingVolume = TestingVolumeType;
topMidPriceLine = 'best-static-offer-price';
bottomMidPriceLine = 'best-static-bid-price';
bidVolTestId(price: string) {
return `bid-vol-${price}`;
}
priceTestId(price: string) {
return `price-${price}`;
}
askVolTestId(price: string) {
return `ask-vol-${price}`;
}
cumulativeVolTestId(price: string) {
return `cumulative-vol-${price}`;
}
verifyOrderBookDisplayed(price: string) {
cy.getByTestId(this.bidVolTestId(price)).should('not.be.empty');
cy.getByTestId(this.priceTestId(price))
.invoke('text')
.then(($priceText) => {
$priceText = $priceText.replace('.', '');
expect($priceText).to.equal(price);
});
cy.getByTestId(this.askVolTestId(price)).should('not.be.empty');
cy.getByTestId(this.cumulativeVolTestId(price)).should('not.be.empty');
}
verifyOrderBookRow(
price: string,
expectedBidVol: string,
expectedPrice: string,
expectedAskVol: string,
expectedCumulativeVol: string
) {
cy.getByTestId(this.bidVolTestId(price)).should(
'have.text',
expectedBidVol
);
cy.getByTestId(this.priceTestId(price)).should('have.text', expectedPrice);
cy.getByTestId(this.askVolTestId(price)).should(
'have.text',
expectedAskVol
);
cy.getByTestId(this.cumulativeVolTestId(price)).should(
'have.text',
expectedCumulativeVol
);
}
// Value should be 1, 10 or 100
changePrecision(precisionValue: string) {
cy.getByTestId(this.precisionChange).select(precisionValue);
}
verifyDisplayedVolume(
price: string,
isBuy: boolean,
expectedPercentage: string,
volumeType: TestingVolumeType
) {
let expectedColour = '';
let testId = '';
if (isBuy == true) {
expectedColour = this.bidColour;
} else {
expectedColour = this.askColour;
}
switch (volumeType) {
case TestingVolumeType.BidVolume:
testId = `[data-testid=${this.bidVolTestId(price)}]`;
break;
case TestingVolumeType.AskVolume:
testId = `[data-testid=${this.askVolTestId(price)}]`;
break;
case TestingVolumeType.CumulativeVolume:
testId = `[data-testid=${this.cumulativeVolTestId(price)}]`;
break;
}
cy.get(`${testId} > div`)
.invoke('attr', 'style')
.should('contain', `width: ${expectedPercentage}`)
.should('contain', `background-color: ${expectedColour}`);
}
verifyCumulativeAskBarPercentage(expectedPercentage: string) {
cy.getByTestId(this.cumulativeVolAskBar)
.invoke('attr', 'style')
.should('contain', `width: ${expectedPercentage}`)
.should('contain', `background-color: ${this.askColour}`);
}
verifyCumulativeBidBarPercentage(expectedPercentage: string) {
cy.getByTestId(this.cumulativeVolBidBar)
.invoke('attr', 'style')
.should('contain', `width: ${expectedPercentage}`)
.should('contain', `background-color: ${this.bidColour}`);
}
verifyTopMidPricePosition(expectedPosition: string) {
cy.getByTestId(this.topMidPriceLine)
.invoke('attr', 'style')
.should('contain', `top: ${expectedPosition}px`);
}
verifyBottomMidPricePosition(expectedPosition: string) {
cy.getByTestId(this.bottomMidPriceLine)
.invoke('attr', 'style')
.should('contain', `top: ${expectedPosition}px`);
}
}
enum TestingVolumeType {
BidVolume = 'BidVolume',
AskVolume = 'AskVolume',
CumulativeVolume = 'CumulativeVolume',
}

View File

@ -15,5 +15,5 @@
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.js"]
"include": ["src/**/*.ts", "src/**/*.js", "./declaration.d.ts"]
}

View File

@ -0,0 +1,28 @@
const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
core: { ...rootMain.core, builder: 'webpack5' },
stories: [
...rootMain.stories,
'../src/lib/**/*.stories.mdx',
'../src/lib/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
...rootMain.addons,
'@nrwl/react/plugins/storybook',
'storybook-addon-themes',
],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType });
}
// add your own webpack tweaks if needed
return config;
},
};

View File

@ -0,0 +1 @@
<link rel="stylesheet" href="https://static.vega.xyz/fonts.css" />

View File

@ -0,0 +1,12 @@
import '../src/styles.scss';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
backgrounds: { disable: true },
themes: {
default: 'dark',
list: [
{ name: 'dark', class: ['dark', 'bg-black'], color: '#000' },
{ name: 'light', class: '', color: '#FFF' },
],
},
};

View File

@ -0,0 +1,19 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"outDir": ""
},
"files": [
"../../../node_modules/@nrwl/react/typings/styled-jsx.d.ts",
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"../**/*.spec.ts",
"../**/*.spec.js",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": ["../src/**/*", "*.js"]
}

View File

@ -4,4 +4,4 @@ This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test orderbook` to execute the unit tests via [Jest](https://jestjs.io).
Run `nx test market-depth` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -1,13 +1,8 @@
module.exports = {
displayName: 'orderbook',
displayName: 'market-depth',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
transform: {
'^.+\\.[tj]sx?$': 'ts-jest',
'^.+\\.[tj]sx?$': 'babel-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/market-depth',

View File

@ -0,0 +1,10 @@
const { join } = require('path');
module.exports = {
plugins: {
tailwindcss: {
config: join(__dirname, 'tailwind.config.js'),
},
autoprefixer: {},
},
};

View File

@ -38,6 +38,37 @@
"jestConfig": "libs/market-depth/jest.config.js",
"passWithNoTests": true
}
},
"storybook": {
"executor": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/react",
"port": 4400,
"config": {
"configFolder": "libs/market-depth/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/react",
"outputPath": "dist/storybook/market-depth",
"config": {
"configFolder": "libs/market-depth/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}

View File

@ -1,2 +1,3 @@
export * from './lib/depth-chart';
export * from './lib/orderbook-container';
export * from './lib/__generated__/MarketDepth';

View File

@ -3,6 +3,8 @@
// @generated
// This file was automatically generated and should not be edited.
import { MarketTradingMode } from "@vegaprotocol/types";
// ====================================================
// GraphQL query operation: MarketDepth
// ====================================================
@ -18,9 +20,29 @@ export interface MarketDepth_market_data_market {
export interface MarketDepth_market_data {
__typename: "MarketData";
/**
* the arithmetic average of the best bid price and best offer price.
* the arithmetic average of the best static bid price and best static offer price
*/
midPrice: string;
staticMidPrice: string;
/**
* what state the market is in (auction, continuous etc)
*/
marketTradingMode: MarketTradingMode;
/**
* indicative volume if the auction ended now, 0 if not in auction mode
*/
indicativeVolume: string;
/**
* indicative price if the auction ended now, 0 if not in auction mode
*/
indicativePrice: string;
/**
* the highest price level on an order book for buy orders not including pegged orders.
*/
bestStaticBidPrice: string;
/**
* the lowest price level on an order book for offer orders not including pegged orders.
*/
bestStaticOfferPrice: string;
/**
* market id of the associated mark price
*/

View File

@ -3,6 +3,8 @@
// @generated
// This file was automatically generated and should not be edited.
import { MarketTradingMode } from "@vegaprotocol/types";
// ====================================================
// GraphQL subscription operation: MarketDepthSubscription
// ====================================================
@ -18,9 +20,29 @@ export interface MarketDepthSubscription_marketDepthUpdate_market_data_market {
export interface MarketDepthSubscription_marketDepthUpdate_market_data {
__typename: "MarketData";
/**
* the arithmetic average of the best bid price and best offer price.
* the arithmetic average of the best static bid price and best static offer price
*/
midPrice: string;
staticMidPrice: string;
/**
* what state the market is in (auction, continuous etc)
*/
marketTradingMode: MarketTradingMode;
/**
* indicative volume if the auction ended now, 0 if not in auction mode
*/
indicativeVolume: string;
/**
* indicative price if the auction ended now, 0 if not in auction mode
*/
indicativePrice: string;
/**
* the highest price level on an order book for buy orders not including pegged orders.
*/
bestStaticBidPrice: string;
/**
* the lowest price level on an order book for offer orders not including pegged orders.
*/
bestStaticOfferPrice: string;
/**
* market id of the associated mark price
*/

View File

@ -107,9 +107,9 @@ export const DepthChartContainer = ({ marketId }: DepthChartManagerProps) => {
decimalPlacesRef.current
);
}
draft.midPrice = delta.market.data?.midPrice
draft.midPrice = delta.market.data?.staticMidPrice
? formatMidPrice(
delta.market.data?.midPrice,
delta.market.data?.staticMidPrice,
decimalPlacesRef.current
)
: undefined;
@ -133,8 +133,8 @@ export const DepthChartContainer = ({ marketId }: DepthChartManagerProps) => {
return;
}
dataRef.current = {
midPrice: data.data?.midPrice
? formatMidPrice(data.data?.midPrice, data.decimalPlaces)
midPrice: data.data?.staticMidPrice
? formatMidPrice(data.data?.staticMidPrice, data.decimalPlaces)
: undefined,
data: {
buy:

View File

@ -17,7 +17,12 @@ const MARKET_DEPTH_QUERY = gql`
id
decimalPlaces
data {
midPrice
staticMidPrice
marketTradingMode
indicativeVolume
indicativePrice
bestStaticBidPrice
bestStaticOfferPrice
market {
id
}
@ -48,7 +53,12 @@ export const MARKET_DEPTH_SUBSCRIPTION_QUERY = gql`
market {
id
data {
midPrice
staticMidPrice
marketTradingMode
indicativeVolume
indicativePrice
bestStaticBidPrice
bestStaticOfferPrice
market {
id
}
@ -74,7 +84,7 @@ const sequenceNumbers: Record<string, number> = {};
const update: Update<
MarketDepth_market,
MarketDepthSubscription_marketDepthUpdate
> = (draft, delta, restart) => {
> = (draft, delta, reload) => {
if (delta.market.id !== draft.id) {
return;
}
@ -84,10 +94,11 @@ const update: Update<
}
if (sequenceNumber - 1 !== sequenceNumbers[delta.market.id]) {
sequenceNumbers[delta.market.id] = 0;
restart(true);
reload();
return;
}
sequenceNumbers[delta.market.id] = sequenceNumber;
Object.assign(draft.data, delta.market.data);
if (delta.buy) {
draft.depth.buy = updateLevels(draft.depth.buy ?? [], delta.buy);
}

View File

@ -1,57 +0,0 @@
import type {
MarketDepth_market,
MarketDepth_market_depth_sell,
MarketDepth_market_depth_buy,
} from './__generated__/MarketDepth';
const depthRow = (
price: number
): MarketDepth_market_depth_sell | MarketDepth_market_depth_buy => {
return {
__typename: 'PriceLevel',
price: price.toString(),
volume: Math.round(Math.random() * 100).toString(),
numberOfOrders: Math.round(Math.random() * 20).toString(),
};
};
const sell = (
price: number,
numberOfRecords: number
): MarketDepth_market_depth_sell[] => {
const distance = Math.random() * price * 0.1;
return new Array(numberOfRecords)
.fill(null)
.map(() => depthRow(price + Math.round(Math.random() * distance)));
};
const buy = (
price: number,
numberOfRecords: number
): MarketDepth_market_depth_buy[] => {
const distance = Math.random() * price * 0.1;
return new Array(numberOfRecords)
.fill(null)
.map(() => depthRow(price - Math.round(Math.random() * distance)));
};
export const getMockedData = (id?: string): MarketDepth_market => ({
__typename: 'Market',
id: id || '',
decimalPlaces: 2,
// "positionDecimalPlaces": 0,
data: {
__typename: 'MarketData',
midPrice: '0',
},
depth: {
__typename: 'MarketDepth',
lastTrade: {
__typename: 'Trade',
price: '12350',
},
sell: sell(12350 * 0.99, 100),
buy: buy(12350, 100),
sequenceNumber: '118118448',
},
});

View File

@ -1,31 +1,5 @@
import { useState } from 'react';
import { OrderbookManager } from './orderbook-manager';
import { Button } from '@vegaprotocol/ui-toolkit';
export const OrderbookContainer = ({ marketId }: { marketId: string }) => {
const [resolution, setResolution] = useState(1);
return (
<>
<div className="flex gap-8">
<Button
variant="secondary"
onClick={() => setResolution(resolution * 10)}
appendIconName="minus"
className="flex-1"
>
Zoom out
</Button>
<Button
variant="secondary"
onClick={() => setResolution(Math.max(resolution / 10, 1))}
appendIconName="plus"
className="flex-1"
>
Zoom in
</Button>
</div>
<OrderbookManager resolution={resolution} marketId={marketId} />
</>
);
};
export const OrderbookContainer = ({ marketId }: { marketId: string }) => (
<OrderbookManager marketId={marketId} />
);

View File

@ -1,16 +1,16 @@
import {
compactData,
compactRows,
updateLevels,
updateCompactedData,
updateCompactedRows,
} from './orderbook-data';
import type { OrderbookData } from './orderbook-data';
import type { OrderbookRowData } from './orderbook-data';
import type { MarketDepth_market_depth_sell } from './__generated__/MarketDepth';
import type {
MarketDepthSubscription_marketDepthUpdate_sell,
MarketDepthSubscription_marketDepthUpdate_buy,
} from './__generated__/MarketDepthSubscription';
describe('compactData', () => {
describe('compactRows', () => {
const numberOfRows = 100;
const middle = 1000;
const sell: MarketDepth_market_depth_sell[] = new Array(numberOfRows)
@ -30,26 +30,26 @@ describe('compactData', () => {
numberOfOrders: (numberOfRows - i).toString(),
}));
it('groups data by price and resolution', () => {
expect(compactData(sell, buy, 1).length).toEqual(200);
expect(compactData(sell, buy, 5).length).toEqual(41);
expect(compactData(sell, buy, 10).length).toEqual(21);
expect(compactRows(sell, buy, 1).length).toEqual(200);
expect(compactRows(sell, buy, 5).length).toEqual(41);
expect(compactRows(sell, buy, 10).length).toEqual(21);
});
it('counts cumulative vol', () => {
const orderbookData = compactData(sell, buy, 10);
expect(orderbookData[0].cumulativeVol.ask).toEqual(4950);
expect(orderbookData[0].cumulativeVol.bid).toEqual(0);
expect(orderbookData[10].cumulativeVol.ask).toEqual(390);
expect(orderbookData[10].cumulativeVol.bid).toEqual(579);
expect(orderbookData[orderbookData.length - 1].cumulativeVol.bid).toEqual(
const orderbookRows = compactRows(sell, buy, 10);
expect(orderbookRows[0].cumulativeVol.ask).toEqual(4950);
expect(orderbookRows[0].cumulativeVol.bid).toEqual(0);
expect(orderbookRows[10].cumulativeVol.ask).toEqual(390);
expect(orderbookRows[10].cumulativeVol.bid).toEqual(579);
expect(orderbookRows[orderbookRows.length - 1].cumulativeVol.bid).toEqual(
4950
);
expect(orderbookData[orderbookData.length - 1].cumulativeVol.ask).toEqual(
expect(orderbookRows[orderbookRows.length - 1].cumulativeVol.ask).toEqual(
0
);
});
it('stores volume by level', () => {
const orderbookData = compactData(sell, buy, 10);
expect(orderbookData[0].askByLevel).toEqual({
const orderbookRows = compactRows(sell, buy, 10);
expect(orderbookRows[0].askByLevel).toEqual({
'1095': 5,
'1096': 4,
'1097': 3,
@ -57,7 +57,7 @@ describe('compactData', () => {
'1099': 1,
'1100': 0,
});
expect(orderbookData[orderbookData.length - 1].bidByLevel).toEqual({
expect(orderbookRows[orderbookRows.length - 1].bidByLevel).toEqual({
'901': 0,
'902': 1,
'903': 2,
@ -66,17 +66,17 @@ describe('compactData', () => {
});
it('updates relative data', () => {
const orderbookData = compactData(sell, buy, 10);
expect(orderbookData[0].cumulativeVol.relativeAsk).toEqual(100);
expect(orderbookData[0].cumulativeVol.relativeBid).toEqual(0);
expect(orderbookData[0].relativeAskVol).toEqual(2);
expect(orderbookData[0].relativeBidVol).toEqual(0);
expect(orderbookData[10].cumulativeVol.relativeAsk).toEqual(8);
expect(orderbookData[10].cumulativeVol.relativeBid).toEqual(12);
expect(orderbookData[10].relativeAskVol).toEqual(44);
expect(orderbookData[10].relativeBidVol).toEqual(66);
expect(orderbookData[orderbookData.length - 1].relativeAskVol).toEqual(0);
expect(orderbookData[orderbookData.length - 1].relativeBidVol).toEqual(1);
const orderbookRows = compactRows(sell, buy, 10);
expect(orderbookRows[0].cumulativeVol.relativeAsk).toEqual(100);
expect(orderbookRows[0].cumulativeVol.relativeBid).toEqual(0);
expect(orderbookRows[0].relativeAsk).toEqual(2);
expect(orderbookRows[0].relativeBid).toEqual(0);
expect(orderbookRows[10].cumulativeVol.relativeAsk).toEqual(8);
expect(orderbookRows[10].cumulativeVol.relativeBid).toEqual(12);
expect(orderbookRows[10].relativeAsk).toEqual(44);
expect(orderbookRows[10].relativeBid).toEqual(64);
expect(orderbookRows[orderbookRows.length - 1].relativeAsk).toEqual(0);
expect(orderbookRows[orderbookRows.length - 1].relativeBid).toEqual(1);
});
});
@ -137,8 +137,8 @@ describe('updateLevels', () => {
});
});
describe('updateCompactedData', () => {
const orderbookData: OrderbookData[] = [
describe('updateCompactedRows', () => {
const orderbookRows: OrderbookRowData[] = [
{
price: '120',
cumulativeVol: {
@ -153,8 +153,8 @@ describe('updateCompactedData', () => {
bidByLevel: {},
ask: 10,
bid: 0,
relativeAskVol: 25,
relativeBidVol: 0,
relativeAsk: 25,
relativeBid: 0,
},
{
price: '100',
@ -174,8 +174,8 @@ describe('updateCompactedData', () => {
},
ask: 40,
bid: 40,
relativeAskVol: 100,
relativeBidVol: 100,
relativeAsk: 100,
relativeBid: 100,
},
{
price: '80',
@ -191,8 +191,8 @@ describe('updateCompactedData', () => {
},
ask: 0,
bid: 10,
relativeAskVol: 0,
relativeBidVol: 25,
relativeAsk: 0,
relativeBid: 25,
},
];
const resolution = 10;
@ -210,18 +210,18 @@ describe('updateCompactedData', () => {
volume: '10',
numberOfOrders: '10',
};
const updatedData = updateCompactedData(
orderbookData,
const updatedRows = updateCompactedRows(
orderbookRows,
[sell],
[buy],
resolution
);
expect(updatedData[0].ask).toEqual(20);
expect(updatedData[0].askByLevel?.[120]).toEqual(10);
expect(updatedData[0].cumulativeVol.ask).toEqual(60);
expect(updatedData[2].bid).toEqual(20);
expect(updatedData[2].bidByLevel?.[80]).toEqual(10);
expect(updatedData[2].cumulativeVol.bid).toEqual(60);
expect(updatedRows[0].ask).toEqual(20);
expect(updatedRows[0].askByLevel?.[120]).toEqual(10);
expect(updatedRows[0].cumulativeVol.ask).toEqual(60);
expect(updatedRows[2].bid).toEqual(20);
expect(updatedRows[2].bidByLevel?.[80]).toEqual(10);
expect(updatedRows[2].cumulativeVol.bid).toEqual(60);
});
it('remove row', () => {
@ -237,13 +237,13 @@ describe('updateCompactedData', () => {
volume: '0',
numberOfOrders: '0',
};
const updatedData = updateCompactedData(
orderbookData,
const updatedRows = updateCompactedRows(
orderbookRows,
[sell],
[buy],
resolution
);
expect(updatedData.length).toEqual(1);
expect(updatedRows.length).toEqual(1);
});
it('add new row at the end', () => {
@ -259,17 +259,17 @@ describe('updateCompactedData', () => {
volume: '5',
numberOfOrders: '5',
};
const updatedData = updateCompactedData(
orderbookData,
const updatedRows = updateCompactedRows(
orderbookRows,
[sell],
[buy],
resolution
);
expect(updatedData.length).toEqual(5);
expect(updatedData[0].price).toEqual('130');
expect(updatedData[0].cumulativeVol.ask).toEqual(55);
expect(updatedData[4].price).toEqual('60');
expect(updatedData[4].cumulativeVol.bid).toEqual(55);
expect(updatedRows.length).toEqual(5);
expect(updatedRows[0].price).toEqual('130');
expect(updatedRows[0].cumulativeVol.ask).toEqual(55);
expect(updatedRows[4].price).toEqual('60');
expect(updatedRows[4].cumulativeVol.bid).toEqual(55);
});
it('add new row in the middle', () => {
@ -285,18 +285,18 @@ describe('updateCompactedData', () => {
volume: '5',
numberOfOrders: '5',
};
const updatedData = updateCompactedData(
orderbookData,
const updatedRows = updateCompactedRows(
orderbookRows,
[sell],
[buy],
resolution
);
expect(updatedData.length).toEqual(5);
expect(updatedData[1].price).toEqual('110');
expect(updatedData[1].cumulativeVol.ask).toEqual(45);
expect(updatedData[0].cumulativeVol.ask).toEqual(55);
expect(updatedData[3].price).toEqual('90');
expect(updatedData[3].cumulativeVol.bid).toEqual(45);
expect(updatedData[4].cumulativeVol.bid).toEqual(55);
expect(updatedRows.length).toEqual(5);
expect(updatedRows[1].price).toEqual('110');
expect(updatedRows[1].cumulativeVol.ask).toEqual(45);
expect(updatedRows[0].cumulativeVol.ask).toEqual(55);
expect(updatedRows[3].price).toEqual('90');
expect(updatedRows[3].cumulativeVol.bid).toEqual(45);
expect(updatedRows[4].cumulativeVol.bid).toEqual(55);
});
});

View File

@ -1,12 +1,16 @@
import produce from 'immer';
import groupBy from 'lodash/groupBy';
import { VolumeType } from '@vegaprotocol/react-helpers';
import { MarketTradingMode } from '@vegaprotocol/types';
import type {
MarketDepth_market_depth_sell,
MarketDepth_market_depth_buy,
MarketDepth_market_data,
} from './__generated__/MarketDepth';
import type {
MarketDepthSubscription_marketDepthUpdate_sell,
MarketDepthSubscription_marketDepthUpdate_buy,
MarketDepthSubscription_marketDepthUpdate_market_data,
} from './__generated__/MarketDepthSubscription';
export interface CumulativeVol {
@ -16,28 +20,32 @@ export interface CumulativeVol {
relativeAsk?: number;
}
export interface OrderbookData {
export interface OrderbookRowData {
price: string;
bid: number;
bidByLevel: Record<string, number>;
relativeBidVol?: number;
relativeBid?: number;
ask: number;
askByLevel: Record<string, number>;
relativeAskVol?: number;
relativeAsk?: number;
cumulativeVol: CumulativeVol;
}
const getGroupPrice = (price: string, resolution: number) => {
export type OrderbookData = Partial<
Omit<MarketDepth_market_data, '__typename' | 'market'>
> & { rows: OrderbookRowData[] | null };
export const getPriceLevel = (price: string | bigint, resolution: number) => {
const p = BigInt(price);
const r = BigInt(resolution);
let groupPrice = (p / r) * r;
if (p - groupPrice >= resolution / 2) {
groupPrice += BigInt(resolution);
let priceLevel = (p / r) * r;
if (p - priceLevel >= resolution / 2) {
priceLevel += BigInt(resolution);
}
return groupPrice.toString();
return priceLevel.toString();
};
const getMaxVolumes = (orderbookData: OrderbookData[]) => ({
const getMaxVolumes = (orderbookData: OrderbookRowData[]) => ({
bid: Math.max(...orderbookData.map((data) => data.bid)),
ask: Math.max(...orderbookData.map((data) => data.ask)),
cumulativeVol: Math.max(
@ -50,13 +58,14 @@ const getMaxVolumes = (orderbookData: OrderbookData[]) => ({
const toPercentValue = (value?: number) => Math.ceil((value ?? 0) * 100);
/**
* @summary Updates relativeAskVol, relativeBidVol, cumulativeVol.relativeAsk, cumulativeVol.relativeBid
* @summary Updates relativeAsk, relativeBid, cumulativeVol.relativeAsk, cumulativeVol.relativeBid
*/
const updateRelativeData = (data: OrderbookData[]) => {
const updateRelativeData = (data: OrderbookRowData[]) => {
const { bid, ask, cumulativeVol } = getMaxVolumes(data);
const maxBidAsk = Math.max(bid, ask);
data.forEach((data) => {
data.relativeAskVol = toPercentValue(data.ask / ask);
data.relativeBidVol = toPercentValue(data.bid / bid);
data.relativeAsk = toPercentValue(data.ask / maxBidAsk);
data.relativeBid = toPercentValue(data.bid / maxBidAsk);
data.cumulativeVol.relativeAsk = toPercentValue(
data.cumulativeVol.ask / cumulativeVol
);
@ -66,37 +75,37 @@ const updateRelativeData = (data: OrderbookData[]) => {
});
};
const createData = (
export const createRow = (
price: string,
volume = 0,
dataType?: 'sell' | 'buy'
): OrderbookData => ({
dataType?: VolumeType
): OrderbookRowData => ({
price,
ask: dataType === 'sell' ? volume : 0,
bid: dataType === 'buy' ? volume : 0,
ask: dataType === VolumeType.ask ? volume : 0,
bid: dataType === VolumeType.bid ? volume : 0,
cumulativeVol: {
ask: dataType === 'sell' ? volume : 0,
bid: dataType === 'buy' ? volume : 0,
ask: dataType === VolumeType.ask ? volume : 0,
bid: dataType === VolumeType.bid ? volume : 0,
},
askByLevel: dataType === 'sell' ? { [price]: volume } : {},
bidByLevel: dataType === 'buy' ? { [price]: volume } : {},
askByLevel: dataType === VolumeType.ask ? { [price]: volume } : {},
bidByLevel: dataType === VolumeType.bid ? { [price]: volume } : {},
});
const mapRawData =
(dataType: 'sell' | 'buy') =>
(dataType: VolumeType.ask | VolumeType.bid) =>
(
data:
| MarketDepth_market_depth_sell
| MarketDepthSubscription_marketDepthUpdate_sell
| MarketDepth_market_depth_buy
| MarketDepthSubscription_marketDepthUpdate_buy
): OrderbookData =>
createData(data.price, Number(data.volume), dataType);
): OrderbookRowData =>
createRow(data.price, Number(data.volume), dataType);
/**
* @summary merges sell amd buy data, orders by price desc, group by price level, counts cumulative and relative values
*/
export const compactData = (
export const compactRows = (
sell:
| (
| MarketDepth_market_depth_sell
@ -112,25 +121,25 @@ export const compactData = (
resolution: number
) => {
// map raw sell data to OrderbookData
const askOrderbookData = [...(sell ?? [])].map<OrderbookData>(
mapRawData('sell')
const askOrderbookData = [...(sell ?? [])].map<OrderbookRowData>(
mapRawData(VolumeType.ask)
);
// map raw buy data to OrderbookData
const bidOrderbookData = [...(buy ?? [])].map<OrderbookData>(
mapRawData('buy')
const bidOrderbookData = [...(buy ?? [])].map<OrderbookRowData>(
mapRawData(VolumeType.bid)
);
// group by price level
const groupedByLevel = groupBy<OrderbookData>(
const groupedByLevel = groupBy<OrderbookRowData>(
[...askOrderbookData, ...bidOrderbookData],
(row) => getGroupPrice(row.price, resolution)
(row) => getPriceLevel(row.price, resolution)
);
// create single OrderbookData from grouped OrderbookData[], sum volumes and atore volume by level
const orderbookData = Object.keys(groupedByLevel).reduce<OrderbookData[]>(
const orderbookData = Object.keys(groupedByLevel).reduce<OrderbookRowData[]>(
(rows, price) =>
rows.concat(
groupedByLevel[price].reduce<OrderbookData>(
groupedByLevel[price].reduce<OrderbookRowData>(
(a, c) => ({
...a,
ask: a.ask + c.ask,
@ -138,7 +147,7 @@ export const compactData = (
bid: (a.bid ?? 0) + (c.bid ?? 0),
bidByLevel: Object.assign(a.bidByLevel, c.bidByLevel),
}),
createData(price)
createRow(price)
)
),
[]
@ -175,9 +184,9 @@ export const compactData = (
* @param modifiedIndex
* @returns max (sell) or min (buy) modified index in draft data, mutates draft
*/
const partiallyUpdateCompactedData = (
dataType: 'sell' | 'buy',
draft: OrderbookData[],
const partiallyUpdateCompactedRows = (
dataType: VolumeType,
draft: OrderbookRowData[],
delta:
| MarketDepthSubscription_marketDepthUpdate_sell
| MarketDepthSubscription_marketDepthUpdate_buy,
@ -186,26 +195,27 @@ const partiallyUpdateCompactedData = (
) => {
const { price } = delta;
const volume = Number(delta.volume);
const groupPrice = getGroupPrice(price, resolution);
const volKey = dataType === 'sell' ? 'ask' : 'bid';
const oppositeVolKey = dataType === 'sell' ? 'bid' : 'ask';
const volByLevelKey = dataType === 'sell' ? 'askByLevel' : 'bidByLevel';
const resolveModifiedIndex = dataType === 'sell' ? Math.max : Math.min;
let index = draft.findIndex((data) => data.price === groupPrice);
const priceLevel = getPriceLevel(price, resolution);
const isAskDataType = dataType === VolumeType.ask;
const volKey = isAskDataType ? 'ask' : 'bid';
const oppositeVolKey = isAskDataType ? 'bid' : 'ask';
const volByLevelKey = isAskDataType ? 'askByLevel' : 'bidByLevel';
const resolveModifiedIndex = isAskDataType ? Math.max : Math.min;
let index = draft.findIndex((data) => data.price === priceLevel);
if (index !== -1) {
modifiedIndex = resolveModifiedIndex(modifiedIndex, index);
draft[index][volKey] =
draft[index][volKey] - (draft[index][volByLevelKey][price] || 0) + volume;
draft[index][volByLevelKey][price] = volume;
} else {
const newData: OrderbookData = createData(groupPrice, volume, dataType);
index = draft.findIndex((data) => BigInt(data.price) < BigInt(groupPrice));
const newData: OrderbookRowData = createRow(priceLevel, volume, dataType);
index = draft.findIndex((data) => BigInt(data.price) < BigInt(priceLevel));
if (index !== -1) {
draft.splice(index, 0, newData);
newData.cumulativeVol[oppositeVolKey] =
draft[index + (groupPrice === 'sell' ? -1 : 1)].cumulativeVol[
draft[index + (isAskDataType ? -1 : 1)]?.cumulativeVol[
oppositeVolKey
];
] ?? 0;
modifiedIndex = resolveModifiedIndex(modifiedIndex, index);
} else {
draft.push(newData);
@ -218,35 +228,35 @@ const partiallyUpdateCompactedData = (
/**
* Updates OrderbookData[] with new data received from subscription - mutates input
*
* @param orderbookData
* @param rows
* @param sell
* @param buy
* @param resolution
* @returns void
*/
export const updateCompactedData = (
orderbookData: OrderbookData[],
export const updateCompactedRows = (
rows: OrderbookRowData[],
sell: MarketDepthSubscription_marketDepthUpdate_sell[] | null,
buy: MarketDepthSubscription_marketDepthUpdate_buy[] | null,
resolution: number
) =>
produce(orderbookData, (draft) => {
produce(rows, (draft) => {
let sellModifiedIndex = -1;
sell?.forEach((buy) => {
sellModifiedIndex = partiallyUpdateCompactedData(
'sell',
sell?.forEach((delta) => {
sellModifiedIndex = partiallyUpdateCompactedRows(
VolumeType.ask,
draft,
buy,
delta,
resolution,
sellModifiedIndex
);
});
let buyModifiedIndex = draft.length;
buy?.forEach((sell) => {
buyModifiedIndex = partiallyUpdateCompactedData(
'buy',
buy?.forEach((delta) => {
buyModifiedIndex = partiallyUpdateCompactedRows(
VolumeType.bid,
draft,
sell,
delta,
resolution,
buyModifiedIndex
);
@ -283,6 +293,25 @@ export const updateCompactedData = (
updateRelativeData(draft);
});
export const mapMarketData = (
data:
| MarketDepth_market_data
| MarketDepthSubscription_marketDepthUpdate_market_data
| 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
* @param levels
@ -317,3 +346,69 @@ export const updateLevels = (
});
return levels;
};
export interface MockDataGeneratorParams {
numberOfSellRows: number;
numberOfBuyRows: number;
overlap: number;
midPrice: number;
bestStaticBidPrice: number;
bestStaticOfferPrice: number;
indicativePrice?: number;
indicativeVolume?: number;
resolution: number;
}
export const generateMockData = ({
numberOfSellRows,
numberOfBuyRows,
midPrice,
overlap,
bestStaticBidPrice,
bestStaticOfferPrice,
indicativePrice,
indicativeVolume,
resolution,
}: MockDataGeneratorParams) => {
let matrix = new Array(numberOfSellRows).fill(undefined);
let price = midPrice + (numberOfSellRows - Math.ceil(overlap / 2) + 1);
const sell: MarketDepth_market_depth_sell[] = matrix.map((row, i) => ({
__typename: 'PriceLevel',
price: (price -= 1).toString(),
volume: (numberOfSellRows - i + 1).toString(),
numberOfOrders: '',
}));
price += overlap;
matrix = new Array(numberOfBuyRows).fill(undefined);
const buy: MarketDepth_market_depth_buy[] = matrix.map((row, i) => ({
__typename: 'PriceLevel',
price: (price -= 1).toString(),
volume: (i + 2).toString(),
numberOfOrders: '',
}));
const rows = compactRows(sell, buy, resolution);
return {
rows,
resolution,
indicativeVolume: indicativeVolume?.toString(),
...mapMarketData(
{
__typename: 'MarketData',
staticMidPrice: '',
marketTradingMode:
overlap > 0
? MarketTradingMode.BatchAuction
: MarketTradingMode.Continuous,
bestStaticBidPrice: bestStaticBidPrice.toString(),
bestStaticOfferPrice: bestStaticOfferPrice.toString(),
indicativePrice: indicativePrice?.toString() ?? '',
indicativeVolume: indicativeVolume?.toString() ?? '',
market: {
__typename: 'Market',
id: '',
},
},
resolution
),
};
};

View File

@ -1,44 +1,54 @@
import throttle from 'lodash/throttle';
import produce from 'immer';
import { AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { Orderbook } from './orderbook';
import { useDataProvider } from '@vegaprotocol/react-helpers';
import { marketDepthDataProvider } from './market-depth-data-provider';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { MarketDepthSubscription_marketDepthUpdate } from './__generated__/MarketDepthSubscription';
import { compactData, updateCompactedData } from './orderbook-data';
import {
compactRows,
updateCompactedRows,
mapMarketData,
} from './orderbook-data';
import type { OrderbookData } from './orderbook-data';
interface OrderbookManagerProps {
marketId: string;
resolution: number;
}
export const OrderbookManager = ({
marketId,
resolution,
}: OrderbookManagerProps) => {
export const OrderbookManager = ({ marketId }: OrderbookManagerProps) => {
const [resolution, setResolution] = useState(1);
const variables = useMemo(() => ({ marketId }), [marketId]);
const resolutionRef = useRef(resolution);
const [orderbookData, setOrderbookData] = useState<OrderbookData[] | null>(
null
);
const dataRef = useRef<OrderbookData[] | null>(null);
const [orderbookData, setOrderbookData] = useState<OrderbookData>({
rows: null,
});
const dataRef = useRef<OrderbookData>({ rows: null });
const setOrderbookDataThrottled = useRef(throttle(setOrderbookData, 1000));
const update = useCallback(
(delta: MarketDepthSubscription_marketDepthUpdate) => {
if (!dataRef.current) {
if (!dataRef.current.rows) {
return false;
}
dataRef.current = updateCompactedData(
dataRef.current,
delta.sell,
delta.buy,
resolutionRef.current
);
dataRef.current = produce(dataRef.current, (draft) => {
Object.assign(draft, delta.market.data);
draft.rows = updateCompactedRows(
draft.rows ?? [],
delta.sell,
delta.buy,
resolutionRef.current
);
Object.assign(
draft,
mapMarketData(delta.market.data, resolutionRef.current)
);
});
setOrderbookDataThrottled.current(dataRef.current);
return true;
},
// using resolutionRef.current to avoid using resolution as a dependency - it will cause data provider restart on resolution change
[]
);
@ -50,11 +60,15 @@ export const OrderbookManager = ({
useEffect(() => {
if (!data) {
dataRef.current = null;
dataRef.current = { rows: null };
setOrderbookData(dataRef.current);
return;
}
dataRef.current = compactData(data.depth.sell, data.depth.buy, resolution);
dataRef.current = {
...data.data,
rows: compactRows(data.depth.sell, data.depth.buy, resolution),
...mapMarketData(data.data, resolution),
};
setOrderbookData(dataRef.current);
}, [data, resolution]);
@ -66,8 +80,10 @@ export const OrderbookManager = ({
return (
<AsyncRenderer loading={loading} error={error} data={data}>
<Orderbook
data={orderbookData}
{...orderbookData}
decimalPlaces={data?.decimalPlaces ?? 0}
resolution={resolution}
onResolutionChange={(resolution: number) => setResolution(resolution)}
/>
</AsyncRenderer>
);

View File

@ -4,41 +4,64 @@ import {
Vol,
CumulativeVol,
addDecimalsFormatNumber,
VolumeType,
} from '@vegaprotocol/react-helpers';
interface OrderbookRowProps {
bid: number;
relativeBidVol?: number;
price: string;
ask: number;
relativeAskVol?: number;
bid: number;
cumulativeAsk?: number;
cumulativeBid?: number;
cumulativeRelativeAsk?: number;
cumulativeRelativeBid?: number;
decimalPlaces: number;
indicativeVolume?: string;
price: string;
relativeAsk?: number;
relativeBid?: number;
}
export const OrderbookRow = React.memo(
({
bid,
relativeBidVol,
price,
ask,
relativeAskVol,
decimalPlaces,
bid,
cumulativeAsk,
cumulativeBid,
cumulativeRelativeAsk,
cumulativeRelativeBid,
decimalPlaces,
indicativeVolume,
price,
relativeAsk,
relativeBid,
}: OrderbookRowProps) => {
return (
<>
<Vol value={bid} relativeValue={relativeBidVol} type="bid" />
<Vol
testId={`bid-vol-${price}`}
value={bid}
relativeValue={relativeBid}
type={VolumeType.bid}
/>
<PriceCell
testId={`price-${price}`}
value={BigInt(price)}
valueFormatted={addDecimalsFormatNumber(price, decimalPlaces)}
/>
<Vol value={ask} relativeValue={relativeAskVol} type="ask" />
<Vol
testId={`ask-vol-${price}`}
value={ask}
relativeValue={relativeAsk}
type={VolumeType.ask}
/>
<CumulativeVol
testId={`cumulative-vol-${price}`}
bid={cumulativeBid}
ask={cumulativeAsk}
relativeAsk={cumulativeRelativeAsk}
relativeBid={cumulativeRelativeBid}
indicativeVolume={indicativeVolume}
className="pr-4"
/>
</>
);

View File

@ -0,0 +1,22 @@
$scrollbar-width: 6px;
/* Works on Firefox */
.scroll {
scrollbar-width: thin;
scrollbar-color: #999 #333;
}
/* Works on Chrome, Edge, and Safari */
.scroll::-webkit-scrollbar {
width: $scrollbar-width;
background-color: #999;
}
.scroll::-webkit-scrollbar-thumb {
width: $scrollbar-width;
background-color: #333;
}
.scroll::-webkit-scrollbar-track {
box-shadow: inset 0 0 #{$scrollbar-width} rgb(0 0 0 / 30%);
background-color: #999;
}

View File

@ -1,10 +1,165 @@
import { render } from '@testing-library/react';
import Orderbook from './orderbook';
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
import { generateMockData } from './orderbook-data';
import { Orderbook, rowHeight } from './orderbook';
describe('Orderbook', () => {
it('should render successfully', () => {
const { baseElement } = render(<Orderbook data={null} decimalPlaces={4} />);
expect(baseElement).toBeTruthy();
const params = {
numberOfSellRows: 100,
numberOfBuyRows: 100,
midPrice: 122900,
bestStaticBidPrice: 122905,
bestStaticOfferPrice: 122895,
decimalPlaces: 3,
overlap: 10,
indicativePrice: 122900,
indicativeVolume: 11,
resolution: 1,
};
const onResolutionChange = jest.fn();
const decimalPlaces = 3;
it('should scroll to mid price on init', async () => {
window.innerHeight = 11 * rowHeight;
const result = render(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData(params)}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
});
it('should keep mid price row in the middle', async () => {
window.innerHeight = 11 * rowHeight;
const result = render(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData(params)}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
result.rerender(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData({
...params,
numberOfSellRows: params.numberOfSellRows - 1,
})}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
expect(result.getByTestId('scroll').scrollTop).toBe(90 * rowHeight);
});
it('should scroll to mid price when it will change', async () => {
window.innerHeight = 11 * rowHeight;
const result = render(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData(params)}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
result.rerender(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData({
...params,
bestStaticBidPrice: params.bestStaticBidPrice + 1,
bestStaticOfferPrice: params.bestStaticOfferPrice + 1,
})}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
expect(result.getByTestId('scroll').scrollTop).toBe(90 * rowHeight);
});
it('should should keep price it the middle', async () => {
window.innerHeight = 11 * rowHeight;
const result = render(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData(params)}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
const scrollElement = result.getByTestId('scroll');
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
scrollElement.scrollTop = 92 * rowHeight;
fireEvent.scroll(scrollElement);
result.rerender(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData({
...params,
numberOfSellRows: params.numberOfSellRows - 1,
})}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
});
it('should should get back to mid price on click', async () => {
window.innerHeight = 11 * rowHeight;
const result = render(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData(params)}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
const scrollElement = result.getByTestId('scroll');
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
scrollElement.scrollTop = 0;
fireEvent.scroll(scrollElement);
expect(result.getByTestId('scroll').scrollTop).toBe(0);
const scrollToMidPriceButton = result.getByTestId('scroll-to-midprice');
fireEvent.click(scrollToMidPriceButton);
expect(result.getByTestId('scroll').scrollTop).toBe(91 * rowHeight);
});
it('should should get back to mid price on resolution change', async () => {
window.innerHeight = 11 * rowHeight;
const result = render(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData(params)}
onResolutionChange={onResolutionChange}
/>
);
await waitFor(() => screen.getByTestId(`bid-vol-${params.midPrice}`));
const scrollElement = result.getByTestId('scroll');
expect(scrollElement.scrollTop).toBe(91 * rowHeight);
scrollElement.scrollTop = 0;
fireEvent.scroll(scrollElement);
expect(result.getByTestId('scroll').scrollTop).toBe(0);
const resolutionSelect = result.getByTestId(
'resolution'
) as HTMLSelectElement;
fireEvent.change(resolutionSelect, { target: { value: '10' } });
expect(onResolutionChange.mock.calls.length).toBe(1);
expect(onResolutionChange.mock.calls[0][0]).toBe(10);
result.rerender(
<Orderbook
decimalPlaces={decimalPlaces}
{...generateMockData({
...params,
resolution: 10,
})}
onResolutionChange={onResolutionChange}
/>
);
expect(result.getByTestId('scroll').scrollTop).toBe(5 * rowHeight);
});
});

View File

@ -0,0 +1,71 @@
import type { Story, Meta } from '@storybook/react';
import { generateMockData } from './orderbook-data';
import type { MockDataGeneratorParams } from './orderbook-data';
import { Orderbook } from './orderbook';
import { useState } from 'react';
type Props = Omit<MockDataGeneratorParams, 'resolution'> & {
decimalPlaces: number;
};
const OrderbokMockDataProvider = ({ decimalPlaces, ...props }: Props) => {
const [resolution, setResolution] = useState(1);
return (
<div className="absolute inset-0 dark:bg-black dark:text-white-60 bg-white text-black-60">
<div
className="absolute left-0 top-0 bottom-0"
style={{ width: '400px' }}
>
<Orderbook
onResolutionChange={setResolution}
decimalPlaces={decimalPlaces}
{...generateMockData({ ...props, resolution })}
/>
</div>
</div>
);
};
export default {
component: OrderbokMockDataProvider,
title: 'Orderbook',
} as Meta;
const Template: Story<Props> = (args) => <OrderbokMockDataProvider {...args} />;
export const Continuous = Template.bind({});
Continuous.args = {
numberOfSellRows: 100,
numberOfBuyRows: 100,
midPrice: 1000,
bestStaticBidPrice: 1000,
bestStaticOfferPrice: 1000,
decimalPlaces: 3,
overlap: -1,
};
export const Auction = Template.bind({});
Auction.args = {
numberOfSellRows: 100,
numberOfBuyRows: 100,
midPrice: 122900,
bestStaticBidPrice: 122905,
bestStaticOfferPrice: 122895,
decimalPlaces: 3,
overlap: 10,
indicativePrice: 122900,
indicativeVolume: 11,
};
export const Empty = Template.bind({});
Empty.args = {
numberOfSellRows: 0,
numberOfBuyRows: 0,
midPrice: 0,
bestStaticBidPrice: 0,
bestStaticOfferPrice: 0,
decimalPlaces: 3,
overlap: 0,
indicativePrice: 0,
indicativeVolume: 0,
};

View File

@ -1,36 +1,395 @@
import { t } from '@vegaprotocol/react-helpers';
import { OrderbookRow } from './orderbook-row';
import type { OrderbookData } from './orderbook-data';
import styles from './orderbook.module.scss';
interface OrderbookProps {
data: OrderbookData[] | null;
import {
Fragment,
useEffect,
useLayoutEffect,
useRef,
useState,
useMemo,
useCallback,
} from 'react';
import classNames from 'classnames';
import { formatNumber, t } from '@vegaprotocol/react-helpers';
import { MarketTradingMode } from '@vegaprotocol/types';
import { OrderbookRow } from './orderbook-row';
import { createRow, getPriceLevel } from './orderbook-data';
import { Icon, Splash } from '@vegaprotocol/ui-toolkit';
import type { OrderbookData, OrderbookRowData } from './orderbook-data';
interface OrderbookProps extends OrderbookData {
decimalPlaces: number;
resolution: number;
onResolutionChange: (resolution: number) => void;
}
export const Orderbook = ({ data, decimalPlaces }: OrderbookProps) => (
<>
<div className="grid grid-cols-4 gap-4 border-b-1 text-ui-small mb-2 pb-2">
<div>{t('Bid Vol')}</div>
<div>{t('Price')}</div>
<div>{t('Ask Vol')}</div>
<div>{t('Cumulative Vol')}</div>
</div>
<div className="grid grid-cols-4 gap-4 text-right text-ui-small">
{data?.map((data) => (
<OrderbookRow
key={data.price}
price={data.price}
decimalPlaces={decimalPlaces}
bid={data.bid}
relativeBidVol={data.relativeBidVol}
cumulativeRelativeBid={data.cumulativeVol.relativeBid}
ask={data.ask}
relativeAskVol={data.relativeAskVol}
cumulativeRelativeAsk={data.cumulativeVol.relativeAsk}
/>
))}
</div>
</>
const horizontalLine = (top: string, testId: string) => (
<div
className="border-b-1 absolute inset-x-0"
style={{ top }}
data-testid={testId}
></div>
);
const getNumberOfRows = (
rows: OrderbookRowData[] | null,
resolution: number
) => {
if (!rows || !rows.length) {
return 0;
}
if (rows.length === 1) {
return 1;
}
return (
Number(BigInt(rows[0].price) - BigInt(rows[rows.length - 1].price)) /
resolution +
1
);
};
const getRowsToRender = (
rows: OrderbookRowData[] | null,
resolution: number,
offset: number,
limit: number
): OrderbookRowData[] | null => {
if (!rows || !rows.length) {
return rows;
}
if (rows.length === 1) {
return rows;
}
const selectedRows: OrderbookRowData[] = [];
let price = BigInt(rows[0].price) - BigInt(offset * resolution);
let index = Math.max(
rows.findIndex((row) => BigInt(row.price) <= price) - 1,
-1
);
while (selectedRows.length < limit && index + 1 < rows.length) {
if (rows[index + 1].price === price.toString()) {
selectedRows.push(rows[index + 1]);
index += 1;
} else {
const row = createRow(price.toString());
row.cumulativeVol = {
bid: rows[index].cumulativeVol.bid,
relativeBid: rows[index].cumulativeVol.relativeBid,
ask: rows[index + 1].cumulativeVol.ask,
relativeAsk: rows[index + 1].cumulativeVol.relativeAsk,
};
selectedRows.push(row);
}
price -= BigInt(resolution);
}
return selectedRows;
};
// 17px of row height plus 5px gap
export const rowHeight = 22;
// buffer size in rows
const bufferSize = 30;
// margin size in px, when reached scrollOffset will be updated
const marginSize = bufferSize * 0.9 * rowHeight;
export const Orderbook = ({
rows,
bestStaticBidPrice,
bestStaticOfferPrice,
marketTradingMode,
indicativeVolume,
indicativePrice,
decimalPlaces,
resolution,
onResolutionChange,
}: OrderbookProps) => {
const scrollElement = useRef<HTMLDivElement>(null);
// scroll offset for which rendered rows are selected, will change after user will scroll to margin of rendered data
const [scrollOffset, setScrollOffset] = useState(0);
// actual scrollTop of scrollElement current element
const scrollTopRef = useRef(0);
// 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
const priceInCenter = useRef<string>();
const [lockOnMidPrice, setLockOnMidPrice] = useState(true);
const resolutionRef = useRef(resolution);
// stores rows[0].price value
const [maxPriceLevel, setMaxPriceLevel] = useState('');
const [viewportHeight, setViewportHeight] = useState(window.innerHeight);
const numberOfRows = useMemo(
() => getNumberOfRows(rows, resolution),
[rows, resolution]
);
const updateScrollOffset = useCallback(
(scrollTop: number) => {
if (Math.abs(scrollOffset - scrollTop) > marginSize) {
setScrollOffset(scrollTop);
}
},
[scrollOffset]
);
const onScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const { scrollTop } = event.currentTarget;
updateScrollOffset(scrollTop);
if (scrollTop === scrollTopRef.current) {
return;
}
priceInCenter.current = (
BigInt(resolution) + // extra row on very top - sticky header
BigInt(maxPriceLevel) -
BigInt(
Math.floor((scrollTop + Math.floor(viewportHeight / 2)) / rowHeight)
) *
BigInt(resolution)
).toString();
if (lockOnMidPrice) {
setLockOnMidPrice(false);
}
scrollTopRef.current = scrollTop;
},
[
resolution,
lockOnMidPrice,
maxPriceLevel,
viewportHeight,
updateScrollOffset,
]
);
const scrollToPrice = useCallback(
(price: string) => {
if (scrollElement.current && maxPriceLevel) {
let scrollTop =
// distance in rows between midPrice and price from first row * row Height
(Number(
(BigInt(maxPriceLevel) - BigInt(price)) / BigInt(resolution)
) +
1) * // add one row for sticky header
rowHeight;
// minus half height of viewport plus half of row
scrollTop -= Math.ceil((viewportHeight - rowHeight) / 2);
// adjust to current rows position
scrollTop +=
(scrollTopRef.current % rowHeight) - (scrollTop % rowHeight);
const priceCenterScrollOffset = Math.max(0, Math.min(scrollTop));
if (scrollTopRef.current !== priceCenterScrollOffset) {
updateScrollOffset(priceCenterScrollOffset);
scrollTopRef.current = priceCenterScrollOffset;
scrollElement.current.scrollTop = priceCenterScrollOffset;
}
}
},
[maxPriceLevel, resolution, viewportHeight, updateScrollOffset]
);
useEffect(() => {
const newMaxPriceLevel = rows?.[0]?.price ?? '';
if (newMaxPriceLevel !== maxPriceLevel) {
setMaxPriceLevel(newMaxPriceLevel);
}
}, [rows, maxPriceLevel]);
const scrollToMidPrice = useCallback(() => {
if (!bestStaticOfferPrice || !bestStaticBidPrice) {
return;
}
priceInCenter.current = undefined;
setLockOnMidPrice(true);
scrollToPrice(
getPriceLevel(
BigInt(bestStaticOfferPrice) +
(BigInt(bestStaticBidPrice) - BigInt(bestStaticOfferPrice)) /
BigInt(2),
resolution
)
);
}, [bestStaticOfferPrice, bestStaticBidPrice, scrollToPrice, resolution]);
// adjust scroll position to keep selected price in center
useLayoutEffect(() => {
if (resolutionRef.current !== resolution) {
priceInCenter.current = undefined;
resolutionRef.current = resolution;
setLockOnMidPrice(true);
}
if (priceInCenter.current) {
scrollToPrice(priceInCenter.current);
} else {
scrollToMidPrice();
}
}, [scrollToMidPrice, scrollToPrice, resolution]);
// handles viewport resize
useEffect(() => {
function handleResize() {
if (scrollElement.current) {
setViewportHeight(
scrollElement.current.clientHeight || window.innerHeight
);
}
}
window.addEventListener('resize', handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
}, []);
const renderedRows = useMemo(() => {
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
);
return {
offset,
limit,
data: getRowsToRender(rows, resolution, offset, limit),
};
}, [rows, scrollOffset, resolution, viewportHeight, numberOfRows]);
const paddingTop = renderedRows.offset * rowHeight;
const paddingBottom =
(numberOfRows - renderedRows.offset - renderedRows.limit) * rowHeight;
const minPriceLevel =
BigInt(maxPriceLevel) - BigInt(Math.floor(numberOfRows * resolution));
const hasData = renderedRows.data && renderedRows.data.length !== 0;
return (
<div
className={`h-full overflow-auto relative ${styles['scroll']}`}
onScroll={onScroll}
ref={scrollElement}
data-testid="scroll"
>
<div
className="sticky top-0 grid grid-cols-4 gap-5 text-right border-b-1 text-ui-small mb-2 pb-2 bg-white dark:bg-black z-10"
style={{ gridAutoRows: '17px' }}
>
<div>{t('Bid Vol')}</div>
<div>{t('Price')}</div>
<div>{t('Ask Vol')}</div>
<div className="pr-4">{t('Cumulative Vol')}</div>
</div>
<div
style={{
paddingTop: `${paddingTop}px`,
paddingBottom: `${paddingBottom}px`,
minHeight: `calc(100% - ${2 * rowHeight}px)`,
background: hasData
? 'linear-gradient(#999,#999) 24.6% 0/1px 100% no-repeat, linear-gradient(#999,#999) 50% 0/1px 100% no-repeat, linear-gradient(#999,#999) 75.2% 0/1px 100% no-repeat'
: 'none',
}}
>
{hasData ? (
<div
className="grid grid-cols-4 gap-5 text-right text-ui-small"
style={{
gridAutoRows: '17px',
}}
>
{renderedRows.data?.map((data) => {
return (
<Fragment key={data.price}>
<OrderbookRow
price={(BigInt(data.price) / BigInt(resolution)).toString()}
decimalPlaces={decimalPlaces - Math.log10(resolution)}
bid={data.bid}
relativeBid={data.relativeBid}
cumulativeBid={data.cumulativeVol.bid}
cumulativeRelativeBid={data.cumulativeVol.relativeBid}
ask={data.ask}
relativeAsk={data.relativeAsk}
cumulativeAsk={data.cumulativeVol.ask}
cumulativeRelativeAsk={data.cumulativeVol.relativeAsk}
indicativeVolume={
marketTradingMode !== MarketTradingMode.Continuous &&
indicativePrice === data.price
? indicativeVolume
: undefined
}
/>
</Fragment>
);
})}
</div>
) : (
<div className="inset-0 absolute">
<Splash>{t('No data')}</Splash>
</div>
)}
</div>
<div
className="sticky bottom-0 grid grid-cols-4 gap-5 border-t-1 text-ui-small mt-2 pb-2 bg-white dark:bg-black z-10"
style={{ gridAutoRows: '17px' }}
>
<div className="text-ui-small col-start-2">
<select
onChange={(e) => onResolutionChange(Number(e.currentTarget.value))}
value={resolution}
className="block bg-black-25 dark:bg-white-25 text-black dark:text-white focus-visible:shadow-focus dark:focus-visible:shadow-focus-dark focus-visible:outline-0 font-mono w-100 text-right w-full h-full"
data-testid="resolution"
>
{new Array(3)
.fill(null)
.map((v, i) => Math.pow(10, i))
.map((r) => (
<option key={r} value={r}>
{formatNumber(0, decimalPlaces - Math.log10(r))}
</option>
))}
</select>
</div>
<div className="text-ui-small col-start-4">
<button
onClick={scrollToMidPrice}
className={classNames('w-full h-full', {
hidden: lockOnMidPrice,
block: !lockOnMidPrice,
})}
data-testid="scroll-to-midprice"
>
Go to mid
<span className="ml-4">
<Icon name="th-derived" />
</span>
</button>
</div>
</div>
{maxPriceLevel &&
bestStaticBidPrice &&
BigInt(bestStaticBidPrice) < BigInt(maxPriceLevel) &&
BigInt(bestStaticBidPrice) > minPriceLevel &&
horizontalLine(
`${(
((BigInt(maxPriceLevel) - BigInt(bestStaticBidPrice)) /
BigInt(resolution) +
BigInt(1)) *
BigInt(rowHeight) -
BigInt(3)
).toString()}px`,
'best-static-bid-price'
)}
{maxPriceLevel &&
bestStaticOfferPrice &&
BigInt(bestStaticOfferPrice) <= BigInt(maxPriceLevel) &&
BigInt(bestStaticOfferPrice) > minPriceLevel &&
horizontalLine(
`${(
((BigInt(maxPriceLevel) - BigInt(bestStaticOfferPrice)) /
BigInt(resolution) +
BigInt(2)) *
BigInt(rowHeight) -
BigInt(3)
).toString()}px`,
'best-static-offer-price'
)}
</div>
);
};
export default Orderbook;

View File

@ -1 +1,2 @@
import '@testing-library/jest-dom';
import 'jest-canvas-mock';

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,17 @@
const { join } = require('path');
const { createGlobPatternsForDependencies } = require('@nrwl/next/tailwind');
const theme = require('../tailwindcss-config/src/theme');
const vegaCustomClasses = require('../tailwindcss-config/src/vega-custom-classes');
module.exports = {
content: [
join(__dirname, 'src/**/*.{ts,tsx,html,mdx}'),
join(__dirname, '.storybook/preview.js'),
...createGlobPatternsForDependencies(__dirname),
],
darkMode: 'class',
theme: {
extend: theme,
},
plugins: [vegaCustomClasses],
};

View File

@ -20,6 +20,9 @@
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./.storybook/tsconfig.json"
}
]
}

View File

@ -16,7 +16,11 @@
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
"**/*.test.jsx",
"**/*.stories.ts",
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
"types": ["jest", "node", "@testing-library/jest-dom"]
},
"include": [
"**/*.test.ts",

View File

@ -20,16 +20,16 @@ export function useDataProvider<Data, Delta>(
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | undefined>(undefined);
const flushRef = useRef<(() => void) | undefined>(undefined);
const restartRef = useRef<((force?: boolean) => void) | undefined>(undefined);
const reloadRef = useRef<((force?: boolean) => void) | undefined>(undefined);
const initialized = useRef<boolean>(false);
const flush = useCallback(() => {
if (flushRef.current) {
flushRef.current();
}
}, []);
const restart = useCallback((force = false) => {
if (restartRef.current) {
restartRef.current(force);
const reload = useCallback((force = false) => {
if (reloadRef.current) {
reloadRef.current(force);
}
}, []);
const callback = useCallback(
@ -48,14 +48,14 @@ export function useDataProvider<Data, Delta>(
[update]
);
useEffect(() => {
const { unsubscribe, flush, restart } = dataProvider(
const { unsubscribe, flush, reload } = dataProvider(
callback,
client,
variables
);
flushRef.current = flush;
restartRef.current = restart;
reloadRef.current = reload;
return unsubscribe;
}, [client, initialized, dataProvider, callback, variables]);
return { data, loading, error, flush, restart };
return { data, loading, error, flush, reload };
}

View File

@ -25,7 +25,7 @@ export interface Subscribe<Data, Delta> {
variables?: OperationVariables
): {
unsubscribe: () => void;
restart: (force?: boolean) => void;
reload: (forceReset?: boolean) => void;
flush: () => void;
};
}
@ -34,7 +34,11 @@ export interface Subscribe<Data, Delta> {
type Query<Result> = DocumentNode | TypedDocumentNode<Result, any>;
export interface Update<Data, Delta> {
(draft: Draft<Data>, delta: Delta, restart: (force?: boolean) => void): void;
(
draft: Draft<Data>,
delta: Delta,
reload: (forceReset?: boolean) => void
): void;
}
interface GetData<QueryData, Data> {
@ -47,7 +51,7 @@ interface GetDelta<SubscriptionData, Delta> {
/**
* @param subscriptionQuery query that will be used for subscription
* @param update function that will be executed on each onNext, it should update data base on delta, it can restart data provider
* @param update function that will be execued on each onNext, it should update data base on delta, it can reload data provider
* @param getData transforms received query data to format that will be stored in data provider
* @param getDelta transforms delta data to format that will be stored in data provider
* @param fetchPolicy
@ -105,7 +109,7 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
while (updateQueue.length) {
const delta = updateQueue.shift();
if (delta) {
update(draft, delta, restart);
update(draft, delta, reload);
}
}
});
@ -123,13 +127,13 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
}
};
// restart function is passed to update and as a returned by subscribe function
const restart = (hard = false) => {
// reload function is passed to update and as a returned by subscribe function
const reload = (forceReset = false) => {
if (loading) {
return;
}
// hard reset on demand or when there is no apollo subscription yet
if (hard || !subscription) {
if (forceReset || !subscription) {
reset();
initialize();
} else {
@ -165,7 +169,7 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
updateQueue.push(delta);
} else {
const newData = produce(data, (draft) => {
update(draft, delta, restart);
update(draft, delta, reload);
});
if (newData === data) {
return;
@ -174,12 +178,12 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
notifyAll(delta);
}
},
() => restart()
() => reload()
);
await initialFetch();
};
const reset = () => {
const reset = (clean = true) => {
if (subscription) {
subscription.unsubscribe();
subscription = undefined;
@ -198,7 +202,6 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
}
};
//
return (callback, c, v) => {
callbacks.push(callback);
if (callbacks.length === 1) {
@ -210,7 +213,7 @@ function makeDataProviderInternal<QueryData, Data, SubscriptionData, Delta>(
}
return {
unsubscribe: () => unsubscribe(callback),
restart,
reload,
flush: () => notify(callback),
};
};
@ -243,7 +246,7 @@ const memoize = <Data, Delta>(
/**
* @param query Query<QueryData>
* @param subscriptionQuery Query<SubscriptionData> query that will be used for subscription
* @param update Update<Data, Delta> function that will be executed on each onNext, it should update data base on delta, it can restart data provider
* @param update Update<Data, Delta> function that will be executed on each onNext, it should update data base on delta, it can reload data provider
* @param getData transforms received query data to format that will be stored in data provider
* @param getDelta transforms delta data to format that will be stored in data provider
* @param fetchPolicy
@ -252,12 +255,12 @@ const memoize = <Data, Delta>(
* const marketMidPriceProvider = makeDataProvider<QueryData, Data, SubscriptionData, Delta>(
* gql`query MarketMidPrice($marketId: ID!) { market(id: $marketId) { data { midPrice } } }`,
* gql`subscription MarketMidPriceSubscription($marketId: ID!) { marketDepthUpdate(marketId: $marketId) { market { data { midPrice } } } }`,
* (draft: Draft<Data>, delta: Delta, restart: (force?: boolean) => void) => { draft.midPrice = delta.midPrice }
* (draft: Draft<Data>, delta: Delta, reload: (forceReset?: boolean) => void) => { draft.midPrice = delta.midPrice }
* (data:QueryData) => data.market.data.midPrice
* (delta:SubscriptionData) => delta.marketData.market
* )
*
* const { unsubscribe, flush, restart } = marketMidPriceProvider(
* const { unsubscribe, flush, reload } = marketMidPriceProvider(
* ({ data, error, loading, delta }) => { ... },
* apolloClient,
* { id: '1fd726454fa1220038acbf6ff9ac701d8b8bf3f2d77c93a4998544471dc58747' }

View File

@ -1,13 +1,16 @@
import React from 'react';
import type { ICellRendererParams } from 'ag-grid-community';
import classNames from 'classnames';
import { BID_COLOR, ASK_COLOR } from './vol-cell';
const INTERSECT_COLOR = 'darkgray';
export interface CumulativeVolProps {
ask?: number;
bid?: number;
relativeAsk?: number;
relativeBid?: number;
indicativeVolume?: string;
testId?: string;
className?: string;
}
export interface ICumulativeVolCellProps extends ICellRendererParams {
@ -15,44 +18,57 @@ export interface ICumulativeVolCellProps extends ICellRendererParams {
}
export const CumulativeVol = React.memo(
({ relativeAsk, relativeBid }: CumulativeVolProps) => {
const bid = relativeBid ? (
({
relativeAsk,
relativeBid,
ask,
bid,
indicativeVolume,
testId,
className,
}: CumulativeVolProps) => {
const askBar = relativeAsk ? (
<div
className="h-full absolute top-0 right-0"
style={{
width: `${relativeBid}%`,
backgroundColor:
relativeAsk && relativeAsk > relativeBid
? INTERSECT_COLOR
: BID_COLOR,
}}
></div>
) : null;
const ask = relativeAsk ? (
<div
className="h-full absolute top-0 left-0"
data-testid="ask-bar"
className="absolute left-0 top-0"
style={{
height: relativeBid && relativeAsk ? '50%' : '100%',
width: `${relativeAsk}%`,
backgroundColor:
relativeBid && relativeBid > relativeAsk
? INTERSECT_COLOR
: ASK_COLOR,
backgroundColor: ASK_COLOR,
}}
></div>
) : null;
const bidBar = relativeBid ? (
<div
data-testid="bid-bar"
className="absolute top-0 left-0"
style={{
height: relativeBid && relativeAsk ? '50%' : '100%',
top: relativeBid && relativeAsk ? '50%' : '0',
width: `${relativeBid}%`,
backgroundColor: BID_COLOR,
}}
></div>
) : null;
const volume = indicativeVolume ? (
<span className="relative">({indicativeVolume})</span>
) : (
<span className="relative">
{ask ? ask : null}
{ask && bid ? '/' : null}
{bid ? bid : null}
</span>
);
return (
<div className="h-full relative" data-testid="vol">
{relativeBid && relativeAsk && relativeBid > relativeAsk ? (
<>
{ask}
{bid}
</>
) : (
<>
{bid}
{ask}
</>
)}
<div
className={classNames('h-full relative', className)}
data-testid={testId || 'cummulative-vol'}
>
{askBar}
{bidBar}
{volume}
</div>
);
}

View File

@ -2,10 +2,11 @@ import React from 'react';
export interface IPriceCellProps {
value: number | bigint | null | undefined;
valueFormatted: string;
testId?: string;
}
export const PriceCell = React.memo(
({ value, valueFormatted }: IPriceCellProps) => {
({ value, valueFormatted, testId }: IPriceCellProps) => {
if (
(!value && value !== 0) ||
(typeof value === 'number' && isNaN(Number(value)))
@ -13,7 +14,10 @@ export const PriceCell = React.memo(
return <span data-testid="price">-</span>;
}
return (
<span className="font-mono relative text-ui-small" data-testid="price">
<span
className="font-mono relative text-ui-small"
data-testid={testId || 'price'}
>
{valueFormatted}
</span>
);

View File

@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import * as React from 'react';
import { PriceFlashCell } from './price-flash-cell';

View File

@ -1,11 +1,17 @@
import React from 'react';
import type { ICellRendererParams } from 'ag-grid-community';
import { PriceCell } from './price-cell';
import classNames from 'classnames';
export enum VolumeType {
bid,
ask,
}
export interface VolProps {
value: number | bigint | null | undefined;
relativeValue?: number;
type: 'bid' | 'ask';
type: VolumeType;
testId?: string;
}
export interface IVolCellProps extends ICellRendererParams {
value: number | bigint | null | undefined;
@ -15,23 +21,28 @@ export interface IVolCellProps extends ICellRendererParams {
export const BID_COLOR = 'darkgreen';
export const ASK_COLOR = 'maroon';
export const Vol = React.memo(({ value, relativeValue, type }: VolProps) => {
if ((!value && value !== 0) || isNaN(Number(value))) {
return <div data-testid="vol">-</div>;
export const Vol = React.memo(
({ value, relativeValue, type, testId }: VolProps) => {
if ((!value && value !== 0) || isNaN(Number(value))) {
return <div data-testid="vol">-</div>;
}
return (
<div className="relative" data-testid={testId || 'vol'}>
<div
className={classNames('h-full absolute top-0', {
'left-0': type === VolumeType.bid,
'right-0': type === VolumeType.ask,
})}
style={{
width: relativeValue ? `${relativeValue}%` : '0%',
backgroundColor: type === VolumeType.bid ? BID_COLOR : ASK_COLOR,
}}
></div>
<PriceCell value={value} valueFormatted={value.toString()} />
</div>
);
}
return (
<div className="relative" data-testid="vol">
<div
className="h-full absolute top-0 left-0"
style={{
width: relativeValue ? `${relativeValue}%` : '0%',
backgroundColor: type === 'bid' ? BID_COLOR : ASK_COLOR,
}}
></div>
<PriceCell value={value} valueFormatted={value.toString()} />
</div>
);
});
);
Vol.displayName = 'Vol';

View File

@ -79,6 +79,7 @@ module.exports = {
0: '0px',
2: '0.125rem',
4: '0.25rem',
5: '0.3125rem',
8: '0.5rem',
12: '0.75rem',
16: '1rem',

View File

@ -1,6 +1,7 @@
import '../src/styles.scss';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
backgrounds: { disable: true },
/*themes: {
default: 'dark',
list: [

View File

@ -97,6 +97,7 @@
"@storybook/addon-a11y": "^6.4.19",
"@storybook/addon-essentials": "~6.4.12",
"@storybook/builder-webpack5": "~6.4.12",
"@storybook/core-server": "~6.4.12",
"@storybook/manager-webpack5": "~6.4.12",
"@storybook/react": "~6.4.12",
"@svgr/webpack": "^5.4.0",

536
yarn.lock
View File

@ -4158,6 +4158,23 @@
global "^4.4.0"
regenerator-runtime "^0.13.7"
"@storybook/addons@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-6.4.22.tgz#e165407ca132c2182de2d466b7ff7c5644b6ad7b"
integrity sha512-P/R+Jsxh7pawKLYo8MtE3QU/ilRFKbtCewV/T1o5U/gm8v7hKQdFz3YdRMAra4QuCY8bQIp7MKd2HrB5aH5a1A==
dependencies:
"@storybook/api" "6.4.22"
"@storybook/channels" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/router" "6.4.22"
"@storybook/theming" "6.4.22"
"@types/webpack-env" "^1.16.0"
core-js "^3.8.2"
global "^4.4.0"
regenerator-runtime "^0.13.7"
"@storybook/api@6.4.21", "@storybook/api@^6.0.0":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.4.21.tgz#efee41ae7bde37f6fe43ee960fef1a261b1b1dd6"
@ -4181,6 +4198,29 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/api@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/api/-/api-6.4.22.tgz#d63f7ad3ffdd74af01ae35099bff4c39702cf793"
integrity sha512-lAVI3o2hKupYHXFTt+1nqFct942up5dHH6YD7SZZJGyW21dwKC3HK1IzCsTawq3fZAKkgWFgmOO649hKk60yKg==
dependencies:
"@storybook/channels" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/router" "6.4.22"
"@storybook/semver" "^7.3.2"
"@storybook/theming" "6.4.22"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
lodash "^4.17.21"
memoizerific "^1.11.3"
regenerator-runtime "^0.13.7"
store2 "^2.12.0"
telejson "^5.3.2"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/builder-webpack4@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/builder-webpack4/-/builder-webpack4-6.4.21.tgz#5355ab1bfe7ee153e907d8e64c6088fdb7a95676"
@ -4256,6 +4296,81 @@
webpack-hot-middleware "^2.25.1"
webpack-virtual-modules "^0.2.2"
"@storybook/builder-webpack4@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/builder-webpack4/-/builder-webpack4-6.4.22.tgz#d3384b146e97a2b3a6357c6eb8279ff0f1c7f8f5"
integrity sha512-A+GgGtKGnBneRFSFkDarUIgUTI8pYFdLmUVKEAGdh2hL+vLXAz9A46sEY7C8LQ85XWa8TKy3OTDxqR4+4iWj3A==
dependencies:
"@babel/core" "^7.12.10"
"@babel/plugin-proposal-class-properties" "^7.12.1"
"@babel/plugin-proposal-decorators" "^7.12.12"
"@babel/plugin-proposal-export-default-from" "^7.12.1"
"@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1"
"@babel/plugin-proposal-object-rest-spread" "^7.12.1"
"@babel/plugin-proposal-optional-chaining" "^7.12.7"
"@babel/plugin-proposal-private-methods" "^7.12.1"
"@babel/plugin-syntax-dynamic-import" "^7.8.3"
"@babel/plugin-transform-arrow-functions" "^7.12.1"
"@babel/plugin-transform-block-scoping" "^7.12.12"
"@babel/plugin-transform-classes" "^7.12.1"
"@babel/plugin-transform-destructuring" "^7.12.1"
"@babel/plugin-transform-for-of" "^7.12.1"
"@babel/plugin-transform-parameters" "^7.12.1"
"@babel/plugin-transform-shorthand-properties" "^7.12.1"
"@babel/plugin-transform-spread" "^7.12.1"
"@babel/plugin-transform-template-literals" "^7.12.1"
"@babel/preset-env" "^7.12.11"
"@babel/preset-react" "^7.12.10"
"@babel/preset-typescript" "^7.12.7"
"@storybook/addons" "6.4.22"
"@storybook/api" "6.4.22"
"@storybook/channel-postmessage" "6.4.22"
"@storybook/channels" "6.4.22"
"@storybook/client-api" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/components" "6.4.22"
"@storybook/core-common" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/node-logger" "6.4.22"
"@storybook/preview-web" "6.4.22"
"@storybook/router" "6.4.22"
"@storybook/semver" "^7.3.2"
"@storybook/store" "6.4.22"
"@storybook/theming" "6.4.22"
"@storybook/ui" "6.4.22"
"@types/node" "^14.0.10"
"@types/webpack" "^4.41.26"
autoprefixer "^9.8.6"
babel-loader "^8.0.0"
babel-plugin-macros "^2.8.0"
babel-plugin-polyfill-corejs3 "^0.1.0"
case-sensitive-paths-webpack-plugin "^2.3.0"
core-js "^3.8.2"
css-loader "^3.6.0"
file-loader "^6.2.0"
find-up "^5.0.0"
fork-ts-checker-webpack-plugin "^4.1.6"
glob "^7.1.6"
glob-promise "^3.4.0"
global "^4.4.0"
html-webpack-plugin "^4.0.0"
pnp-webpack-plugin "1.6.4"
postcss "^7.0.36"
postcss-flexbugs-fixes "^4.2.1"
postcss-loader "^4.2.0"
raw-loader "^4.0.2"
stable "^0.1.8"
style-loader "^1.3.0"
terser-webpack-plugin "^4.2.3"
ts-dedent "^2.0.0"
url-loader "^4.1.1"
util-deprecate "^1.0.2"
webpack "4"
webpack-dev-middleware "^3.7.3"
webpack-filter-warnings-plugin "^1.2.1"
webpack-hot-middleware "^2.25.1"
webpack-virtual-modules "^0.2.2"
"@storybook/builder-webpack5@~6.4.12":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/builder-webpack5/-/builder-webpack5-6.4.21.tgz#d601676083a263a1f03847b12fe2ad1ecd3865bb"
@ -4332,6 +4447,19 @@
qs "^6.10.0"
telejson "^5.3.2"
"@storybook/channel-postmessage@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/channel-postmessage/-/channel-postmessage-6.4.22.tgz#8be0be1ea1e667a49fb0f09cdfdeeb4a45829637"
integrity sha512-gt+0VZLszt2XZyQMh8E94TqjHZ8ZFXZ+Lv/Mmzl0Yogsc2H+6VzTTQO4sv0IIx6xLbpgG72g5cr8VHsxW5kuDQ==
dependencies:
"@storybook/channels" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/core-events" "6.4.22"
core-js "^3.8.2"
global "^4.4.0"
qs "^6.10.0"
telejson "^5.3.2"
"@storybook/channel-websocket@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/channel-websocket/-/channel-websocket-6.4.21.tgz#46db7dbfb9a37907ab12ba2632c46070557b5a97"
@ -4343,6 +4471,17 @@
global "^4.4.0"
telejson "^5.3.2"
"@storybook/channel-websocket@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/channel-websocket/-/channel-websocket-6.4.22.tgz#d541f69125873123c453757e2b879a75a9266c65"
integrity sha512-Bm/FcZ4Su4SAK5DmhyKKfHkr7HiHBui6PNutmFkASJInrL9wBduBfN8YQYaV7ztr8ezoHqnYRx8sj28jpwa6NA==
dependencies:
"@storybook/channels" "6.4.22"
"@storybook/client-logger" "6.4.22"
core-js "^3.8.2"
global "^4.4.0"
telejson "^5.3.2"
"@storybook/channels@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.4.21.tgz#0f1924963f77ec0c3d82aa643a246824ca9f5fca"
@ -4352,6 +4491,15 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/channels@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-6.4.22.tgz#710f732763d63f063f615898ab1afbe74e309596"
integrity sha512-cfR74tu7MLah1A8Rru5sak71I+kH2e/sY6gkpVmlvBj4hEmdZp4Puj9PTeaKcMXh9DgIDPNA5mb8yvQH6VcyxQ==
dependencies:
core-js "^3.8.2"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-api@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-6.4.21.tgz#6dcf41a9e55b5e38638cd4d032f1ceaec305e0eb"
@ -4378,6 +4526,32 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-api@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-6.4.22.tgz#df14f85e7900b94354c26c584bab53a67c47eae9"
integrity sha512-sO6HJNtrrdit7dNXQcZMdlmmZG1k6TswH3gAyP/DoYajycrTwSJ6ovkarzkO+0QcJ+etgra4TEdTIXiGHBMe/A==
dependencies:
"@storybook/addons" "6.4.22"
"@storybook/channel-postmessage" "6.4.22"
"@storybook/channels" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/store" "6.4.22"
"@types/qs" "^6.9.5"
"@types/webpack-env" "^1.16.0"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
lodash "^4.17.21"
memoizerific "^1.11.3"
qs "^6.10.0"
regenerator-runtime "^0.13.7"
store2 "^2.12.0"
synchronous-promise "^2.0.15"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-logger@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.4.21.tgz#7df21cec4d5426669e828af59232ec44ea19c81a"
@ -4386,6 +4560,14 @@
core-js "^3.8.2"
global "^4.4.0"
"@storybook/client-logger@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-6.4.22.tgz#51abedb7d3c9bc21921aeb153ac8a19abc625cd6"
integrity sha512-LXhxh/lcDsdGnK8kimqfhu3C0+D2ylCSPPQNbU0IsLRmTfbpQYMdyl0XBjPdHiRVwlL7Gkw5OMjYemQgJ02zlw==
dependencies:
core-js "^3.8.2"
global "^4.4.0"
"@storybook/components@6.4.21", "@storybook/components@^6.0.0":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/components/-/components-6.4.21.tgz#77483ef429f96d94cf7d2d8c1af8441ef855a77d"
@ -4416,6 +4598,36 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/components@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/components/-/components-6.4.22.tgz#4d425280240702883225b6a1f1abde7dc1a0e945"
integrity sha512-dCbXIJF9orMvH72VtAfCQsYbe57OP7fAADtR6YTwfCw9Sm1jFuZr8JbblQ1HcrXEoJG21nOyad3Hm5EYVb/sBw==
dependencies:
"@popperjs/core" "^2.6.0"
"@storybook/client-logger" "6.4.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/theming" "6.4.22"
"@types/color-convert" "^2.0.0"
"@types/overlayscrollbars" "^1.12.0"
"@types/react-syntax-highlighter" "11.0.5"
color-convert "^2.0.1"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
lodash "^4.17.21"
markdown-to-jsx "^7.1.3"
memoizerific "^1.11.3"
overlayscrollbars "^1.13.1"
polished "^4.0.5"
prop-types "^15.7.2"
react-colorful "^5.1.2"
react-popper-tooltip "^3.1.1"
react-syntax-highlighter "^13.5.3"
react-textarea-autosize "^8.3.0"
regenerator-runtime "^0.13.7"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/core-client@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-6.4.21.tgz#4882092315c884dca6118202c83a5e6758b7de57"
@ -4442,6 +4654,32 @@
unfetch "^4.2.0"
util-deprecate "^1.0.2"
"@storybook/core-client@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/core-client/-/core-client-6.4.22.tgz#9079eda8a9c8e6ba24b84962a749b1c99668cb2a"
integrity sha512-uHg4yfCBeM6eASSVxStWRVTZrAnb4FT6X6v/xDqr4uXCpCttZLlBzrSDwPBLNNLtCa7ntRicHM8eGKIOD5lMYQ==
dependencies:
"@storybook/addons" "6.4.22"
"@storybook/channel-postmessage" "6.4.22"
"@storybook/channel-websocket" "6.4.22"
"@storybook/client-api" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/preview-web" "6.4.22"
"@storybook/store" "6.4.22"
"@storybook/ui" "6.4.22"
airbnb-js-shims "^2.2.1"
ansi-to-html "^0.6.11"
core-js "^3.8.2"
global "^4.4.0"
lodash "^4.17.21"
qs "^6.10.0"
regenerator-runtime "^0.13.7"
ts-dedent "^2.0.0"
unfetch "^4.2.0"
util-deprecate "^1.0.2"
"@storybook/core-common@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-6.4.21.tgz#7151eeb5f628bec1dc1461df2de4c51fec15ac4c"
@ -4497,6 +4735,61 @@
util-deprecate "^1.0.2"
webpack "4"
"@storybook/core-common@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/core-common/-/core-common-6.4.22.tgz#b00fa3c0625e074222a50be3196cb8052dd7f3bf"
integrity sha512-PD3N/FJXPNRHeQS2zdgzYFtqPLdi3MLwAicbnw+U3SokcsspfsAuyYHZOYZgwO8IAEKy6iCc7TpBdiSJZ/vAKQ==
dependencies:
"@babel/core" "^7.12.10"
"@babel/plugin-proposal-class-properties" "^7.12.1"
"@babel/plugin-proposal-decorators" "^7.12.12"
"@babel/plugin-proposal-export-default-from" "^7.12.1"
"@babel/plugin-proposal-nullish-coalescing-operator" "^7.12.1"
"@babel/plugin-proposal-object-rest-spread" "^7.12.1"
"@babel/plugin-proposal-optional-chaining" "^7.12.7"
"@babel/plugin-proposal-private-methods" "^7.12.1"
"@babel/plugin-syntax-dynamic-import" "^7.8.3"
"@babel/plugin-transform-arrow-functions" "^7.12.1"
"@babel/plugin-transform-block-scoping" "^7.12.12"
"@babel/plugin-transform-classes" "^7.12.1"
"@babel/plugin-transform-destructuring" "^7.12.1"
"@babel/plugin-transform-for-of" "^7.12.1"
"@babel/plugin-transform-parameters" "^7.12.1"
"@babel/plugin-transform-shorthand-properties" "^7.12.1"
"@babel/plugin-transform-spread" "^7.12.1"
"@babel/preset-env" "^7.12.11"
"@babel/preset-react" "^7.12.10"
"@babel/preset-typescript" "^7.12.7"
"@babel/register" "^7.12.1"
"@storybook/node-logger" "6.4.22"
"@storybook/semver" "^7.3.2"
"@types/node" "^14.0.10"
"@types/pretty-hrtime" "^1.0.0"
babel-loader "^8.0.0"
babel-plugin-macros "^3.0.1"
babel-plugin-polyfill-corejs3 "^0.1.0"
chalk "^4.1.0"
core-js "^3.8.2"
express "^4.17.1"
file-system-cache "^1.0.5"
find-up "^5.0.0"
fork-ts-checker-webpack-plugin "^6.0.4"
fs-extra "^9.0.1"
glob "^7.1.6"
handlebars "^4.7.7"
interpret "^2.2.0"
json5 "^2.1.3"
lazy-universal-dotenv "^3.0.1"
picomatch "^2.3.0"
pkg-dir "^5.0.0"
pretty-hrtime "^1.0.3"
resolve-from "^5.0.0"
slash "^3.0.0"
telejson "^5.3.2"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
webpack "4"
"@storybook/core-events@6.4.21", "@storybook/core-events@^6.0.0":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.4.21.tgz#28fff8b10c0d564259edf4439ff8677615ce59c0"
@ -4504,6 +4797,13 @@
dependencies:
core-js "^3.8.2"
"@storybook/core-events@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-6.4.22.tgz#c09b0571951affd4254028b8958a4d8652700989"
integrity sha512-5GYY5+1gd58Gxjqex27RVaX6qbfIQmJxcbzbNpXGNSqwqAuIIepcV1rdCVm6I4C3Yb7/AQ3cN5dVbf33QxRIwA==
dependencies:
core-js "^3.8.2"
"@storybook/core-server@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-6.4.21.tgz#3f60c68bb21fd1b07113b2bbaefd6e0498bdbd68"
@ -4552,6 +4852,54 @@
webpack "4"
ws "^8.2.3"
"@storybook/core-server@~6.4.12":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/core-server/-/core-server-6.4.22.tgz#254409ec2ba49a78b23f5e4a4c0faea5a570a32b"
integrity sha512-wFh3e2fa0un1d4+BJP+nd3FVWUO7uHTqv3OGBfOmzQMKp4NU1zaBNdSQG7Hz6mw0fYPBPZgBjPfsJRwIYLLZyw==
dependencies:
"@discoveryjs/json-ext" "^0.5.3"
"@storybook/builder-webpack4" "6.4.22"
"@storybook/core-client" "6.4.22"
"@storybook/core-common" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/csf-tools" "6.4.22"
"@storybook/manager-webpack4" "6.4.22"
"@storybook/node-logger" "6.4.22"
"@storybook/semver" "^7.3.2"
"@storybook/store" "6.4.22"
"@types/node" "^14.0.10"
"@types/node-fetch" "^2.5.7"
"@types/pretty-hrtime" "^1.0.0"
"@types/webpack" "^4.41.26"
better-opn "^2.1.1"
boxen "^5.1.2"
chalk "^4.1.0"
cli-table3 "^0.6.1"
commander "^6.2.1"
compression "^1.7.4"
core-js "^3.8.2"
cpy "^8.1.2"
detect-port "^1.3.0"
express "^4.17.1"
file-system-cache "^1.0.5"
fs-extra "^9.0.1"
globby "^11.0.2"
ip "^1.1.5"
lodash "^4.17.21"
node-fetch "^2.6.1"
pretty-hrtime "^1.0.3"
prompts "^2.4.0"
regenerator-runtime "^0.13.7"
serve-favicon "^2.5.0"
slash "^3.0.0"
telejson "^5.3.3"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
watchpack "^2.2.0"
webpack "4"
ws "^8.2.3"
"@storybook/core@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/core/-/core-6.4.21.tgz#d92a60a6014df5f88902edfe4fadf1cbdd9ba238"
@ -4583,6 +4931,29 @@
regenerator-runtime "^0.13.7"
ts-dedent "^2.0.0"
"@storybook/csf-tools@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/csf-tools/-/csf-tools-6.4.22.tgz#f6d64bcea1b36114555972acae66a1dbe9e34b5c"
integrity sha512-LMu8MZAiQspJAtMBLU2zitsIkqQv7jOwX7ih5JrXlyaDticH7l2j6Q+1mCZNWUOiMTizj0ivulmUsSaYbpToSw==
dependencies:
"@babel/core" "^7.12.10"
"@babel/generator" "^7.12.11"
"@babel/parser" "^7.12.11"
"@babel/plugin-transform-react-jsx" "^7.12.12"
"@babel/preset-env" "^7.12.11"
"@babel/traverse" "^7.12.11"
"@babel/types" "^7.12.11"
"@mdx-js/mdx" "^1.6.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
core-js "^3.8.2"
fs-extra "^9.0.1"
global "^4.4.0"
js-string-escape "^1.0.1"
lodash "^4.17.21"
prettier ">=2.2.1 <=2.3.0"
regenerator-runtime "^0.13.7"
ts-dedent "^2.0.0"
"@storybook/csf@0.0.2--canary.87bc651.0":
version "0.0.2--canary.87bc651.0"
resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.0.2--canary.87bc651.0.tgz#c7b99b3a344117ef67b10137b6477a3d2750cf44"
@ -4632,6 +5003,48 @@
webpack-dev-middleware "^3.7.3"
webpack-virtual-modules "^0.2.2"
"@storybook/manager-webpack4@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/manager-webpack4/-/manager-webpack4-6.4.22.tgz#eabd674beee901c7f755d9b679e9f969cbab636d"
integrity sha512-nzhDMJYg0vXdcG0ctwE6YFZBX71+5NYaTGkxg3xT7gbgnP1YFXn9gVODvgq3tPb3gcRapjyOIxUa20rV+r8edA==
dependencies:
"@babel/core" "^7.12.10"
"@babel/plugin-transform-template-literals" "^7.12.1"
"@babel/preset-react" "^7.12.10"
"@storybook/addons" "6.4.22"
"@storybook/core-client" "6.4.22"
"@storybook/core-common" "6.4.22"
"@storybook/node-logger" "6.4.22"
"@storybook/theming" "6.4.22"
"@storybook/ui" "6.4.22"
"@types/node" "^14.0.10"
"@types/webpack" "^4.41.26"
babel-loader "^8.0.0"
case-sensitive-paths-webpack-plugin "^2.3.0"
chalk "^4.1.0"
core-js "^3.8.2"
css-loader "^3.6.0"
express "^4.17.1"
file-loader "^6.2.0"
file-system-cache "^1.0.5"
find-up "^5.0.0"
fs-extra "^9.0.1"
html-webpack-plugin "^4.0.0"
node-fetch "^2.6.1"
pnp-webpack-plugin "1.6.4"
read-pkg-up "^7.0.1"
regenerator-runtime "^0.13.7"
resolve-from "^5.0.0"
style-loader "^1.3.0"
telejson "^5.3.2"
terser-webpack-plugin "^4.2.3"
ts-dedent "^2.0.0"
url-loader "^4.1.1"
util-deprecate "^1.0.2"
webpack "4"
webpack-dev-middleware "^3.7.3"
webpack-virtual-modules "^0.2.2"
"@storybook/manager-webpack5@~6.4.12":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/manager-webpack5/-/manager-webpack5-6.4.21.tgz#f8f20c03bed8c3911a3678e637feef1d36bb45f5"
@ -4693,6 +5106,17 @@
npmlog "^5.0.1"
pretty-hrtime "^1.0.3"
"@storybook/node-logger@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/node-logger/-/node-logger-6.4.22.tgz#c4ec00f8714505f44eda7671bc88bb44abf7ae59"
integrity sha512-sUXYFqPxiqM7gGH7gBXvO89YEO42nA4gBicJKZjj9e+W4QQLrftjF9l+mAw2K0mVE10Bn7r4pfs5oEZ0aruyyA==
dependencies:
"@types/npmlog" "^4.1.2"
chalk "^4.1.0"
core-js "^3.8.2"
npmlog "^5.0.1"
pretty-hrtime "^1.0.3"
"@storybook/postinstall@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/postinstall/-/postinstall-6.4.21.tgz#1a0dc4ae0c8bf73fcda3d2abf6f22477dce0a908"
@ -4722,6 +5146,28 @@
unfetch "^4.2.0"
util-deprecate "^1.0.2"
"@storybook/preview-web@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/preview-web/-/preview-web-6.4.22.tgz#58bfc6492503ff4265b50f42a27ea8b0bfcf738a"
integrity sha512-sWS+sgvwSvcNY83hDtWUUL75O2l2LY/GTAS0Zp2dh3WkObhtuJ/UehftzPZlZmmv7PCwhb4Q3+tZDKzMlFxnKQ==
dependencies:
"@storybook/addons" "6.4.22"
"@storybook/channel-postmessage" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/store" "6.4.22"
ansi-to-html "^0.6.11"
core-js "^3.8.2"
global "^4.4.0"
lodash "^4.17.21"
qs "^6.10.0"
regenerator-runtime "^0.13.7"
synchronous-promise "^2.0.15"
ts-dedent "^2.0.0"
unfetch "^4.2.0"
util-deprecate "^1.0.2"
"@storybook/react-docgen-typescript-plugin@1.0.2-canary.253f8c1.0":
version "1.0.2-canary.253f8c1.0"
resolved "https://registry.yarnpkg.com/@storybook/react-docgen-typescript-plugin/-/react-docgen-typescript-plugin-1.0.2-canary.253f8c1.0.tgz#f2da40e6aae4aa586c2fb284a4a1744602c3c7fa"
@ -4782,6 +5228,23 @@
react-router-dom "^6.0.0"
ts-dedent "^2.0.0"
"@storybook/router@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/router/-/router-6.4.22.tgz#e3cc5cd8595668a367e971efb9695bbc122ed95e"
integrity sha512-zeuE8ZgFhNerQX8sICQYNYL65QEi3okyzw7ynF58Ud6nRw4fMxSOHcj2T+nZCIU5ufozRL4QWD/Rg9P2s/HtLw==
dependencies:
"@storybook/client-logger" "6.4.22"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
history "5.0.0"
lodash "^4.17.21"
memoizerific "^1.11.3"
qs "^6.10.0"
react-router "^6.0.0"
react-router-dom "^6.0.0"
ts-dedent "^2.0.0"
"@storybook/semver@^7.3.2":
version "7.3.2"
resolved "https://registry.yarnpkg.com/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0"
@ -4827,6 +5290,27 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/store@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/store/-/store-6.4.22.tgz#f291fbe3639f14d25f875cac86abb209a97d4e2a"
integrity sha512-lrmcZtYJLc2emO+1l6AG4Txm9445K6Pyv9cGAuhOJ9Kks0aYe0YtvMkZVVry0RNNAIv6Ypz72zyKc/QK+tZLAQ==
dependencies:
"@storybook/addons" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/csf" "0.0.2--canary.87bc651.0"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
lodash "^4.17.21"
memoizerific "^1.11.3"
regenerator-runtime "^0.13.7"
slash "^3.0.0"
stable "^0.1.8"
synchronous-promise "^2.0.15"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/theming@6.4.21", "@storybook/theming@^6.0.0":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.4.21.tgz#ea1a33be70c654cb31e5b38fae93f72171e88ef8"
@ -4845,6 +5329,24 @@
resolve-from "^5.0.0"
ts-dedent "^2.0.0"
"@storybook/theming@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-6.4.22.tgz#19097eec0366447ddd0d6917b0e0f81d0ec5e51e"
integrity sha512-NVMKH/jxSPtnMTO4VCN1k47uztq+u9fWv4GSnzq/eezxdGg9ceGL4/lCrNGoNajht9xbrsZ4QvsJ/V2sVGM8wA==
dependencies:
"@emotion/core" "^10.1.1"
"@emotion/is-prop-valid" "^0.8.6"
"@emotion/styled" "^10.0.27"
"@storybook/client-logger" "6.4.22"
core-js "^3.8.2"
deep-object-diff "^1.1.0"
emotion-theming "^10.0.27"
global "^4.4.0"
memoizerific "^1.11.3"
polished "^4.0.5"
resolve-from "^5.0.0"
ts-dedent "^2.0.0"
"@storybook/ui@6.4.21":
version "6.4.21"
resolved "https://registry.yarnpkg.com/@storybook/ui/-/ui-6.4.21.tgz#03b0ba66663f70b706ca29481bedf08a468dad3d"
@ -4879,6 +5381,40 @@
resolve-from "^5.0.0"
store2 "^2.12.0"
"@storybook/ui@6.4.22":
version "6.4.22"
resolved "https://registry.yarnpkg.com/@storybook/ui/-/ui-6.4.22.tgz#49badd7994465d78d984ca4c42533c1c22201c46"
integrity sha512-UVjMoyVsqPr+mkS1L7m30O/xrdIEgZ5SCWsvqhmyMUok3F3tRB+6M+OA5Yy+cIVfvObpA7MhxirUT1elCGXsWQ==
dependencies:
"@emotion/core" "^10.1.1"
"@storybook/addons" "6.4.22"
"@storybook/api" "6.4.22"
"@storybook/channels" "6.4.22"
"@storybook/client-logger" "6.4.22"
"@storybook/components" "6.4.22"
"@storybook/core-events" "6.4.22"
"@storybook/router" "6.4.22"
"@storybook/semver" "^7.3.2"
"@storybook/theming" "6.4.22"
copy-to-clipboard "^3.3.1"
core-js "^3.8.2"
core-js-pure "^3.8.2"
downshift "^6.0.15"
emotion-theming "^10.0.27"
fuse.js "^3.6.1"
global "^4.4.0"
lodash "^4.17.21"
markdown-to-jsx "^7.1.3"
memoizerific "^1.11.3"
polished "^4.0.5"
qs "^6.10.0"
react-draggable "^4.4.3"
react-helmet-async "^1.0.7"
react-sizeme "^3.0.1"
regenerator-runtime "^0.13.7"
resolve-from "^5.0.0"
store2 "^2.12.0"
"@svgr/babel-plugin-add-jsx-attribute@^5.4.0":
version "5.4.0"
resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz#81ef61947bb268eb9d50523446f9c638fb355906"