mirror of
https://github.com/RoboSats/robosats.git
synced 2025-07-17 16:23:13 +00:00
370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
import React, { useEffect, useState, useContext } from 'react';
|
|
import {
|
|
ResponsiveLine,
|
|
type Serie,
|
|
type Datum,
|
|
type PointTooltipProps,
|
|
type PointMouseHandler,
|
|
type Point,
|
|
type CustomLayer,
|
|
} from '@nivo/line';
|
|
import {
|
|
Box,
|
|
CircularProgress,
|
|
Grid,
|
|
IconButton,
|
|
MenuItem,
|
|
Paper,
|
|
Select,
|
|
useTheme,
|
|
} from '@mui/material';
|
|
import { AddCircleOutline, RemoveCircleOutline } from '@mui/icons-material';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { type PublicOrder, type Order } from '../../../models';
|
|
import { matchMedian } from '../../../utils';
|
|
import currencyDict from '../../../../static/assets/currencies.json';
|
|
import getNivoScheme from '../NivoScheme';
|
|
import { type UseAppStoreType, AppContext } from '../../../contexts/AppContext';
|
|
import OrderTooltip from '../helpers/OrderTooltip';
|
|
|
|
interface DepthChartProps {
|
|
maxWidth: number;
|
|
maxHeight: number;
|
|
fillContainer?: boolean;
|
|
elevation?: number;
|
|
onOrderClicked?: (id: number) => void;
|
|
}
|
|
|
|
const DepthChart: React.FC<DepthChartProps> = ({
|
|
maxWidth,
|
|
maxHeight,
|
|
fillContainer = false,
|
|
elevation = 6,
|
|
onOrderClicked = () => null,
|
|
}) => {
|
|
const { book, fav, info, limits, baseUrl } = useContext<UseAppStoreType>(AppContext);
|
|
const { t } = useTranslation();
|
|
const theme = useTheme();
|
|
const [enrichedOrders, setEnrichedOrders] = useState<Order[]>([]);
|
|
const [series, setSeries] = useState<Serie[]>([]);
|
|
const [rangeSteps, setRangeSteps] = useState<number>(8);
|
|
const [xRange, setXRange] = useState<number>(8);
|
|
const [xType, setXType] = useState<string>('premium');
|
|
const [currencyCode, setCurrencyCode] = useState<number>(1);
|
|
const [center, setCenter] = useState<number>();
|
|
|
|
const height = maxHeight < 10 ? 10 : maxHeight;
|
|
const width = maxWidth < 10 ? 10 : maxWidth > 72.8 ? 72.8 : maxWidth;
|
|
|
|
useEffect(() => {
|
|
setCurrencyCode(fav.currency === 0 ? 1 : fav.currency);
|
|
}, [fav.currency]);
|
|
|
|
useEffect(() => {
|
|
if (Object.keys(limits.list).length > 0) {
|
|
const enriched = book.orders.map((order) => {
|
|
// We need to transform all currencies to the same base (ex. USD), we don't have the exchange rate
|
|
// for EUR -> USD, but we know the rate of both to BTC, so we get advantage of it and apply a
|
|
// simple rule of three
|
|
order.base_amount =
|
|
(order.price * limits.list[currencyCode].price) / limits.list[order.currency].price;
|
|
return order;
|
|
});
|
|
setEnrichedOrders(enriched);
|
|
}
|
|
}, [limits.list, book.orders, currencyCode]);
|
|
|
|
useEffect(() => {
|
|
if (enrichedOrders.length > 0) {
|
|
generateSeries();
|
|
}
|
|
}, [enrichedOrders, xRange]);
|
|
|
|
useEffect(() => {
|
|
if (xType === 'base_amount') {
|
|
const prices: number[] = enrichedOrders.map((order) => order?.base_amount || 0);
|
|
|
|
const medianValue = ~~matchMedian(prices);
|
|
const maxValue = prices.sort((a, b) => b - a).slice(0, 1)[0] || 1500;
|
|
const maxRange = maxValue - medianValue;
|
|
const rangeSteps = maxRange / 10;
|
|
|
|
setCenter(medianValue);
|
|
setXRange(maxRange);
|
|
setRangeSteps(rangeSteps);
|
|
} else {
|
|
if (info.last_day_nonkyc_btc_premium === undefined) {
|
|
const premiums: number[] = enrichedOrders.map((order) => order?.premium || 0);
|
|
setCenter(~~matchMedian(premiums));
|
|
} else {
|
|
setCenter(info.last_day_nonkyc_btc_premium);
|
|
}
|
|
setXRange(8);
|
|
setRangeSteps(0.5);
|
|
}
|
|
}, [enrichedOrders, xType, info.last_day_nonkyc_btc_premium, currencyCode]);
|
|
|
|
const generateSeries: () => void = () => {
|
|
const sortedOrders: PublicOrder[] =
|
|
xType === 'base_amount'
|
|
? enrichedOrders.sort(
|
|
(order1, order2) => (order1?.base_amount || 0) - (order2?.base_amount || 0),
|
|
)
|
|
: enrichedOrders.sort((order1, order2) => order1.premium - order2.premium);
|
|
|
|
const sortedBuyOrders: PublicOrder[] = sortedOrders
|
|
.filter((order) => order.type == 0)
|
|
.reverse();
|
|
const sortedSellOrders: PublicOrder[] = sortedOrders.filter((order) => order.type == 1);
|
|
|
|
const buySerie: Datum[] = generateSerie(sortedBuyOrders);
|
|
const sellSerie: Datum[] = generateSerie(sortedSellOrders);
|
|
|
|
const maxX: number = (center || 0) + xRange;
|
|
const minX: number = (center || 0) - xRange;
|
|
|
|
setSeries([
|
|
{
|
|
id: 'buy',
|
|
data: closeSerie(buySerie, maxX, minX),
|
|
},
|
|
{
|
|
id: 'sell',
|
|
data: closeSerie(sellSerie, minX, maxX),
|
|
},
|
|
]);
|
|
};
|
|
|
|
const generateSerie = (orders: PublicOrder[]): Datum[] => {
|
|
if (center == undefined) {
|
|
return [];
|
|
}
|
|
|
|
let sumOrders: number = 0;
|
|
let serie: Datum[] = [];
|
|
orders.forEach((order) => {
|
|
const lastSumOrders = sumOrders;
|
|
sumOrders += (order.satoshis_now || 0) / 100000000;
|
|
const datum: Datum[] = [
|
|
{
|
|
// Vertical Line
|
|
x: xType === 'base_amount' ? order.base_amount : order.premium,
|
|
y: lastSumOrders,
|
|
},
|
|
{
|
|
// PublicOrder Point
|
|
x: xType === 'base_amount' ? order.base_amount : order.premium,
|
|
y: sumOrders,
|
|
order,
|
|
},
|
|
];
|
|
|
|
serie = [...serie, ...datum];
|
|
});
|
|
const inlineSerie = serie.filter((datum: Datum) => {
|
|
return Number(datum.x) > center - xRange && Number(datum.x) < center + xRange;
|
|
});
|
|
|
|
return inlineSerie;
|
|
};
|
|
|
|
const closeSerie = (serie: Datum[], limitBottom: number, limitTop: number): Datum[] => {
|
|
if (serie.length == 0) {
|
|
return [];
|
|
}
|
|
|
|
// If the bottom is not 0, exdens the horizontal bottom line
|
|
if (serie[0].y !== 0) {
|
|
const startingPoint: Datum = {
|
|
x: limitBottom,
|
|
y: serie[0].y,
|
|
};
|
|
serie.unshift(startingPoint);
|
|
}
|
|
|
|
// exdens the horizontal top line
|
|
const endingPoint: Datum = {
|
|
x: limitTop,
|
|
y: serie[serie.length - 1].y,
|
|
};
|
|
|
|
return [...serie, endingPoint];
|
|
};
|
|
|
|
const centerLine: CustomLayer = (props) => (
|
|
<path
|
|
key='center-line'
|
|
d={props.lineGenerator([
|
|
{
|
|
y: 0,
|
|
x: props.xScale(center || 0),
|
|
},
|
|
{
|
|
y: props.innerHeight,
|
|
x: props.xScale(center || 0),
|
|
},
|
|
])}
|
|
fill='none'
|
|
stroke={getNivoScheme(theme).markers?.lineColor}
|
|
strokeWidth={getNivoScheme(theme).markers?.lineStrokeWidth}
|
|
/>
|
|
);
|
|
|
|
const generateTooltip: React.FunctionComponent<PointTooltipProps> = (
|
|
pointTooltip: PointTooltipProps,
|
|
) => {
|
|
const order: PublicOrder = pointTooltip.point.data.order;
|
|
return <OrderTooltip order={order} />;
|
|
};
|
|
|
|
const formatAxisX = (value: number): string => {
|
|
if (xType === 'base_amount') {
|
|
return value.toString();
|
|
}
|
|
return `${value}%`;
|
|
};
|
|
const formatAxisY = (value: number): string => `${value}BTC`;
|
|
const handleOnClick: PointMouseHandler = (point: Point) => {
|
|
onOrderClicked(point.data?.order?.id);
|
|
};
|
|
|
|
const em = theme.typography.fontSize;
|
|
return (
|
|
<Paper
|
|
elevation={elevation}
|
|
style={
|
|
fillContainer
|
|
? { width: '100%', maxHeight: '100%', height: '100%' }
|
|
: { width: `${width}em`, maxHeight: `${height}em` }
|
|
}
|
|
>
|
|
<Paper variant='outlined' style={{ width: '100%', height: '100%' }}>
|
|
{center == undefined || enrichedOrders.length < 1 ? (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
paddingTop: `${(height - 3) / 2 - 1}em`,
|
|
height: `${height}em`,
|
|
}}
|
|
>
|
|
<CircularProgress />
|
|
</div>
|
|
) : (
|
|
<Grid container style={{ paddingTop: '1em' }}>
|
|
<Grid
|
|
container
|
|
direction='row'
|
|
justifyContent='space-around'
|
|
alignItems='flex-start'
|
|
style={{ position: 'absolute' }}
|
|
>
|
|
<Grid
|
|
container
|
|
justifyContent='flex-start'
|
|
alignItems='flex-start'
|
|
style={{ paddingLeft: '1em' }}
|
|
>
|
|
<Select
|
|
variant='standard'
|
|
value={xType}
|
|
onChange={(e) => {
|
|
setXType(e.target.value);
|
|
}}
|
|
>
|
|
<MenuItem value={'premium'}>
|
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
{t('Premium')}
|
|
</div>
|
|
</MenuItem>
|
|
<MenuItem value={'base_amount'}>
|
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
{t('Price')}
|
|
</div>
|
|
</MenuItem>
|
|
</Select>
|
|
</Grid>
|
|
</Grid>
|
|
<Grid container direction='row' justifyContent='center' alignItems='center'>
|
|
<Grid container justifyContent='center' alignItems='center'>
|
|
<Grid item>
|
|
<IconButton
|
|
onClick={() => {
|
|
setXRange(xRange + rangeSteps);
|
|
}}
|
|
>
|
|
<RemoveCircleOutline />
|
|
</IconButton>
|
|
</Grid>
|
|
<Grid item>
|
|
<Box justifyContent='center'>
|
|
{xType === 'base_amount'
|
|
? `${center} ${currencyDict[currencyCode]}`
|
|
: `${center}%`}
|
|
</Box>
|
|
</Grid>
|
|
<Grid item>
|
|
<IconButton
|
|
onClick={() => {
|
|
setXRange(xRange - rangeSteps);
|
|
}}
|
|
disabled={xRange <= 1}
|
|
>
|
|
<AddCircleOutline />
|
|
</IconButton>
|
|
</Grid>
|
|
</Grid>
|
|
</Grid>
|
|
<Grid container style={{ height: `${height * 0.8}em`, padding: '1em' }}>
|
|
<ResponsiveLine
|
|
data={series}
|
|
enableArea={true}
|
|
useMesh={true}
|
|
animate={false}
|
|
crosshairType='cross'
|
|
tooltip={generateTooltip}
|
|
onClick={handleOnClick}
|
|
axisLeft={{
|
|
tickSize: 5,
|
|
format: formatAxisY,
|
|
}}
|
|
axisBottom={{
|
|
tickSize: 5,
|
|
tickRotation:
|
|
xType === 'base_amount' ? (width < 40 ? 45 : 0) : width < 25 ? 45 : 0,
|
|
format: formatAxisX,
|
|
}}
|
|
margin={{
|
|
left: 4.64 * em,
|
|
right: 0.714 * em,
|
|
bottom:
|
|
xType === 'base_amount'
|
|
? width < 40
|
|
? 2.7 * em
|
|
: 1.78 * em
|
|
: width < 25
|
|
? 2.7 * em
|
|
: 1.78 * em,
|
|
top: 0.714 * em,
|
|
}}
|
|
xFormat={(value) => Number(value).toFixed(0)}
|
|
lineWidth={3}
|
|
theme={getNivoScheme(theme)}
|
|
colors={[theme.palette.secondary.main, theme.palette.primary.main]}
|
|
xScale={{
|
|
type: 'linear',
|
|
min: center - xRange,
|
|
max: center + xRange,
|
|
}}
|
|
layers={['axes', 'areas', 'crosshair', 'lines', centerLine, 'slices', 'mesh']}
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
)}
|
|
</Paper>
|
|
</Paper>
|
|
);
|
|
};
|
|
|
|
export default DepthChart;
|