From a233b000d4ef2186db289138ddac222219f22586 Mon Sep 17 00:00:00 2001 From: David Uhlmann Date: Sat, 18 May 2024 15:47:01 +1000 Subject: [PATCH] feat: connect chart and depth components by price --- src/pages/Orderbook/Orderbook.scss | 4 -- src/pages/Orderbook/Orderbook.tsx | 72 ++++++++++++++++++- src/pages/Orderbook/OrderbookChart.tsx | 54 +++++++++++++- .../Orderbook/OrderbookChartConnector.scss | 9 +++ .../Orderbook/OrderbookChartConnector.tsx | 71 ++++++++++++++++++ src/pages/Orderbook/OrderbookList.tsx | 62 ++++++++++++---- 6 files changed, 253 insertions(+), 19 deletions(-) create mode 100644 src/pages/Orderbook/OrderbookChartConnector.scss create mode 100644 src/pages/Orderbook/OrderbookChartConnector.tsx diff --git a/src/pages/Orderbook/Orderbook.scss b/src/pages/Orderbook/Orderbook.scss index 670e03891..aba7f6828 100644 --- a/src/pages/Orderbook/Orderbook.scss +++ b/src/pages/Orderbook/Orderbook.scss @@ -17,9 +17,5 @@ border-top-right-radius: 0; border-bottom-right-radius: 0; } - - .chart-depth-connector { - min-width: paddings.$p-3; - } } } diff --git a/src/pages/Orderbook/Orderbook.tsx b/src/pages/Orderbook/Orderbook.tsx index c93d5b6c9..f3f61683b 100644 --- a/src/pages/Orderbook/Orderbook.tsx +++ b/src/pages/Orderbook/Orderbook.tsx @@ -8,7 +8,8 @@ import TabsCard from '../../components/cards/TabsCard'; import { Tab } from '../../components/Tabs/Tabs'; import OrderbookHeader from './OrderbookHeader'; import OrderbookFooter from './OrderbookFooter'; -import OrderBookChart from './OrderbookChart'; +import OrderBookChart, { ChartPriceAxisInfo } from './OrderbookChart'; +import OrderbookChartConnector from './OrderbookChartConnector'; import OrderBookList from './OrderbookList'; import OrderBookTradesList from './OrderbookTradesList'; import LimitOrderCard from '../../components/cards/LimitOrderCard'; @@ -33,7 +34,9 @@ function Orderbook() { const { data: tokenA } = useToken(denomA); const { data: tokenB } = useToken(denomB); + const [chartPriceAxis, setChartPriceAxis] = useState(); const [depthPriceIndication, setDepthPriceIndication] = useState(); + const [depthPriceOffset, setDepthPriceOffset] = useState(); return (
@@ -50,10 +53,32 @@ function Orderbook() { tokenB={tokenA} priceIndication={depthPriceIndication} setPriceIndication={setDepthPriceIndication} + setPriceAxis={setChartPriceAxis} /> )}
-
+
+ { + if ( + chartPriceAxis && + depthPriceIndication && + depthPriceOffset + ) { + return [ + [ + getChartPricePointOffset( + chartPriceAxis, + depthPriceIndication + ), + depthPriceOffset, + ], + ]; + } + return []; + }, [chartPriceAxis, depthPriceIndication, depthPriceOffset])} + /> +
) : null, }, @@ -102,3 +128,45 @@ function Orderbook() {
); } + +function getPointOffsetPercent( + value: number, + max: number, + min: number = 0, + mode: ChartPriceAxisInfo['mode'] = 0 +): number { + switch (mode) { + // handle linear math + case 0: { + return (value - min) / (max - min); + } + } + // todo: handle other math + return NaN; +} +function getPointOffset( + percent: number, + extent: number, + offset: number = 0 +): number { + return percent * extent + offset; +} + +const chartPriceAxisOffset = { + top: 42, +}; +function getChartPricePointOffset( + chartPriceAxis: ChartPriceAxisInfo, + price: number +) { + return getPointOffset( + getPointOffsetPercent( + price, + chartPriceAxis.from, + chartPriceAxis.to, + chartPriceAxis.mode + ), + chartPriceAxis.height, + chartPriceAxisOffset.top + ); +} diff --git a/src/pages/Orderbook/OrderbookChart.tsx b/src/pages/Orderbook/OrderbookChart.tsx index 43476ea9d..18be1211d 100644 --- a/src/pages/Orderbook/OrderbookChart.tsx +++ b/src/pages/Orderbook/OrderbookChart.tsx @@ -12,6 +12,8 @@ import { SearchSymbolResultItem, Bar, Timezone, + VisiblePriceRange, + PriceScaleMode, IChartWidgetApi, } from 'charting_library'; @@ -40,7 +42,14 @@ const defaultWidgetOptions: Partial = { container: '', locale: 'en', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone as Timezone, - disabled_features: ['header_symbol_search', 'header_compare'], + disabled_features: [ + // disable searching for symbols inside chart: token pair search is on page + 'header_symbol_search', + // disable comparing multiple symbols: can get complicated + 'header_compare', + // disable mouse-down panning behavior: ensures last tick is current time + 'chart_scroll', + ], enabled_features: [], charts_storage_url: 'https://saveload.tradingview.com', charts_storage_api_version: '1.1', @@ -72,15 +81,23 @@ interface BlockRangeRequestQuery extends RequestQuery { type TimeSeriesResolution = 'second' | 'minute' | 'hour' | 'day' | 'month'; +export interface ChartPriceAxisInfo extends VisiblePriceRange { + mode: PriceScaleMode; + height: number; +} export default function OrderBookChart({ tokenA, tokenB, priceIndication, + setPriceAxis, }: { tokenA: Token; tokenB: Token; priceIndication?: number | undefined; setPriceIndication?: React.Dispatch>; + setPriceAxis?: React.Dispatch< + React.SetStateAction + >; }) { const tokenIdA = getTokenId(tokenA); const tokenIdB = getTokenId(tokenB); @@ -502,6 +519,41 @@ export default function OrderBookChart({ } }, [chart, priceIndication]); + useEffect(() => { + if (chart) { + const checkChartAxis = () => { + setPriceAxis?.((chartPriceAxis) => { + const pane = chart?.getPanes()[0]; + const rightPriceScale = pane?.getRightPriceScales()[0]; + const visiblePriceRange = rightPriceScale?.getVisiblePriceRange(); + const newChartPriceAxis: ChartPriceAxisInfo | undefined = + rightPriceScale && visiblePriceRange + ? { + from: visiblePriceRange.from, + to: visiblePriceRange.to, + mode: rightPriceScale.getMode(), + height: pane.getHeight(), + } + : undefined; + if ( + (chartPriceAxis && !newChartPriceAxis) || + (!chartPriceAxis && newChartPriceAxis) || + chartPriceAxis?.from !== newChartPriceAxis?.from || + chartPriceAxis?.to !== newChartPriceAxis?.to || + chartPriceAxis?.mode !== newChartPriceAxis?.mode || + chartPriceAxis?.height !== newChartPriceAxis?.height + ) { + return newChartPriceAxis; + } + return chartPriceAxis; + }); + timeout = setTimeout(checkChartAxis, 100); + }; + let timeout = setTimeout(checkChartAxis, 0); + return () => clearTimeout(timeout); + } + }, [chart, setPriceAxis]); + return
; } diff --git a/src/pages/Orderbook/OrderbookChartConnector.scss b/src/pages/Orderbook/OrderbookChartConnector.scss new file mode 100644 index 000000000..30225a590 --- /dev/null +++ b/src/pages/Orderbook/OrderbookChartConnector.scss @@ -0,0 +1,9 @@ +@use '../../styles/mixins-vars/margins.scss' as margins; +@use '../../styles/mixins-vars/paddings.scss' as paddings; + +.orderbook-page { + .chart-depth-connector { + min-width: paddings.$p-3; + max-width: 50px; + } +} diff --git a/src/pages/Orderbook/OrderbookChartConnector.tsx b/src/pages/Orderbook/OrderbookChartConnector.tsx new file mode 100644 index 000000000..0f369bbb0 --- /dev/null +++ b/src/pages/Orderbook/OrderbookChartConnector.tsx @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react'; +import useResizeObserver from '@react-hook/resize-observer'; + +import './OrderbookChartConnector.scss'; + +type ConnectionPoint = [number, number]; + +export default function OrderbookChartConnector({ + connectionPoints, +}: { + connectionPoints?: ConnectionPoint[]; +}) { + // define what to draw + const draw = useCallback( + (canvas: HTMLCanvasElement | null) => { + const ctx = canvas?.getContext('2d'); + if (canvas && ctx) { + // reset canvas + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + const width = canvas.width; + const height = canvas.height; + ctx?.clearRect(0, 0, width, height); + // draw elements + if (connectionPoints?.length) { + drawConnectionPoints(ctx, connectionPoints); + } + } + + // define drawing functions + function drawConnectionPoints( + ctx: CanvasRenderingContext2D, + connectionPoints: ConnectionPoint[], + width: number = ctx.canvas.width + ) { + ctx.lineWidth = 1; + ctx.lineJoin = 'round'; + ctx.strokeStyle = 'white'; + ctx.beginPath(); + connectionPoints.forEach(([y1, y2]) => { + ctx.moveTo(sharpPoint(0), sharpPoint(y1)); + ctx.lineTo(sharpPoint(0.7 * width), sharpPoint(y1)); + ctx.lineTo(sharpPoint(0.9 * width), sharpPoint(y2)); + ctx.lineTo(sharpPoint(width), sharpPoint(y2)); + }); + ctx.stroke(); + } + + // use points centered at half-pixels for sharp lines + function sharpPoint(value: number): number { + return Math.round(value) + 0.5; + } + }, + [connectionPoints] + ); + + // store ref but also draw on canvas when first found + const [canvas, setCanvas] = useState(null); + const getCanvasRef = useCallback( + (canvas: HTMLCanvasElement | null) => { + setCanvas(canvas); + draw(canvas); + }, + [draw] + ); + + // redraw canvas when the screen size changes + useResizeObserver(canvas, () => draw(canvas)); + + return ; +} diff --git a/src/pages/Orderbook/OrderbookList.tsx b/src/pages/Orderbook/OrderbookList.tsx index a6c7ecedd..000cf6071 100644 --- a/src/pages/Orderbook/OrderbookList.tsx +++ b/src/pages/Orderbook/OrderbookList.tsx @@ -16,7 +16,7 @@ import { TickInfo, priceToTickIndex } from '../../lib/web3/utils/ticks'; import './OrderbookList.scss'; // ensure that a certain amount of liquidity rows are shown in the card -const shownTickRows = 10; +const shownTickRows = 8; const spacingTicks = Array.from({ length: shownTickRows }).map(() => undefined); export default function OrderBookList({ @@ -24,11 +24,13 @@ export default function OrderBookList({ tokenB, priceIndication, setPriceIndication, + setPriceOffset, }: { tokenA: Token; tokenB: Token; priceIndication?: number | undefined; setPriceIndication?: React.Dispatch>; + setPriceOffset?: React.Dispatch>; }) { const [tokenIdA, tokenIdB] = [getTokenId(tokenA), getTokenId(tokenB)]; const [tokenId0, tokenId1] = useOrderedTokenPair([tokenIdA, tokenIdB]) || []; @@ -42,10 +44,34 @@ export default function OrderBookList({ ]; const onHighlightPrice = useCallback( - (tick: TickInfo | undefined) => { - setPriceIndication?.(tick && tick?.price1To0.toNumber()); - }, - [setPriceIndication] + (includeElementHeight: boolean) => + ( + e: React.MouseEvent, + tick: TickInfo | undefined + ) => { + setPriceIndication?.(tick && tick?.price1To0.toNumber()); + const row = e.target as HTMLTableRowElement | null; + const addOffset = (el: HTMLElement): number => { + if (el.tagName === 'TBODY') return 0; + return ( + el.offsetTop + + (el.tagName !== 'TABLE' && el.offsetParent + ? addOffset(el.offsetParent as HTMLElement) + : 0) + ); + }; + setPriceOffset?.( + row && tick + ? addOffset(row) + (includeElementHeight ? row.offsetHeight : 1) + : undefined + ); + }, + [setPriceIndication, setPriceOffset] + ); + + const [onHighlightHighPrice, onHighlightLowPrice] = useMemo( + () => [onHighlightPrice(false), onHighlightPrice(true)], + [onHighlightPrice] ); const [, currentPrice] = useRealtimePrice(tokenA, tokenB); @@ -246,7 +272,7 @@ export default function OrderBookList({ reserveKey="reserve1" priceDecimalPlaces={priceDecimalPlaces} active={price && priceIndication && price <= priceIndication} - onHighlight={onHighlightPrice} + onHighlight={onHighlightHighPrice} /> ); })} @@ -287,7 +313,7 @@ export default function OrderBookList({ reserveKey="reserve0" priceDecimalPlaces={priceDecimalPlaces} active={price && priceIndication && price >= priceIndication} - onHighlight={onHighlightPrice} + onHighlight={onHighlightLowPrice} /> ); })} @@ -314,23 +340,35 @@ function OrderbookListRow({ priceDecimalPlaces?: number; amountDecimalPlaces?: number; active?: boolean | 0; - onHighlight?: (tick: TickInfo | undefined) => void; + onHighlight?: ( + e: React.MouseEvent, + tick: TickInfo | undefined + ) => void; }) { const { data: price } = useSimplePrice(token); // keep track of hover state and use as a price indication in parents const [hovered, setHovered] = useState(false); const onHover = useCallback( - (tick: TickInfo | undefined) => { + ( + e: React.MouseEvent, + tick: TickInfo | undefined + ) => { // set hovered state setHovered(!!tick); // set parent state - onHighlight?.(tick); + onHighlight?.(e, tick); }, [onHighlight] ); - const onHoverIn = useCallback(() => onHover(tick), [onHover, tick]); - const onHoverOut = useCallback(() => onHover(undefined), [onHover]); + const onHoverIn = useCallback>( + (e) => onHover(e, tick), + [onHover, tick] + ); + const onHoverOut = useCallback>( + (e) => onHover(e, undefined), + [onHover] + ); // add empty row if (!tick) {