const Cup = () => {
const {centralizeCamera, cameraDown, cameraUp, readyState, cupSubscribe, cupUnsubscribe, zoomAdd, zoomSub} = useRustWsServer();
const symbol = useSelector((state: AppState) => state.screenerSlice.symbol);
const pair = useSelector((state: AppState) => state.cupSlice.pair);
const rowCount = useSelector((state: AppState) => state.cupSlice.rowCount);
const camera = useSelector((state: AppState) => state.cupSlice.camera);
const zoom = useSelector((state: AppState) => state.cupSlice.zoom);
const cellHeight = useSelector((state: AppState) => state.cupSlice.cellHeight);
const cup: {[key: number]: CupItem} = useSelector((state: AppState) => state.cupSlice.cup);
const bestMicroPrice = useSelector((state: AppState) => state.cupSlice.bestMicroPrice);
const maxVolume = useSelector((state: AppState) => state.cupSlice.maxVolume);
const precision = useSelector((state: AppState) => state.binancePrecision.futures[symbol.toUpperCase()]);
const tickSize = useSelector((state: AppState) => state.binanceTickSize.futures[symbol.toUpperCase()]);
const [dpiScale, setDpiScale] = useState(Math.ceil(window.devicePixelRatio));
const [canvasSize, setCanvasSize] = useState<CanvasSize>({height: 0, width: 0});
const [animationFrameId, setAnimationFrameId] = useState<number|null>(null);
const [zoomedBestMicroPrice, setZoomedBestMicroPrice] = useState<BestMicroPrice|null>(null);
const containerRef = useRef<HTMLDivElement|null>(null);
const canvasRef = useRef<HTMLCanvasElement|null>(null);
const size = useComponentResizeListener(canvasRef);
const dispatch = useDispatch();
const darkMode = useTheme().palette.mode === “dark”;

const draw = useCallback(() => {
if (null === canvasRef || !precision) return;

const context = canvasRef.current?.getContext(“2d”);

if (context) {
cupDrawer.clear(context, canvasSize);
const rowsOnScreenCount = cupTools.getRowsCountOnScreen(
canvasSize.height,
cupOptions().cell.defaultHeight * dpiScale,
);
const realCellHeight = canvasSize.height / rowsOnScreenCount;

if (cellHeight !== realCellHeight) {
dispatch(setCupCellHeight(realCellHeight));
}

if (rowCount !== rowsOnScreenCount) {
dispatch(setCupRowCount(rowsOnScreenCount));

return;
}

if (camera === 0) {
centralizeCamera();

return;
}

const zoomedTickSize = parseFloat(tickSize) * Math.pow(10, precision?.price) * zoom;
const startMicroPrice = camera + rowCount / 2 * zoomedTickSize - zoomedTickSize;

for (let index = 0; index <= rowCount; index++) {
const microPrice = startMicroPrice - index * zoomedTickSize;

if (microPrice < 0) continue;

const item = !cup[microPrice]
? {
futures_price_micro: microPrice,
quantity: 0,
spot_quantity: 0,
side: microPrice <= (zoomedBestMicroPrice?.buy || 0) ? “Buy” : “Sell”,
}
: cup[microPrice];

const yPosition = realCellHeight * index;
const isBestPrice = cupTools.isBestPrice(
item.side,
item.futures_price_micro,
zoomedBestMicroPrice,
);

cupDrawer.drawCell(
context,
item,
maxVolume,
isBestPrice,
yPosition,
realCellHeight,
canvasSize.width,
dpiScale,
darkMode
);

if (isBestPrice) {
cupDrawer.drawFill(
context,
item,
maxVolume,
yPosition,
realCellHeight,
canvasSize.width,
dpiScale,
darkMode
);
}

cupDrawer.drawPrice(
context,
cupTools.microPriceToReal(item.futures_price_micro, precision.price),
item.side,
isBestPrice,
yPosition,
realCellHeight,
canvasSize.width,
dpiScale,
darkMode
);

cupDrawer.drawQuantity(
context,
cupTools.abbreviateNumber(item.quantity),
item.side,
isBestPrice,
yPosition,
realCellHeight,
canvasSize.width,
dpiScale,
darkMode
);
}
}
}, [cup, camera]);

const wheelHandler = (e: WheelEvent) => {
e.preventDefault();
e.deltaY < 0 ? cameraUp() : cameraDown();
};

useEffect(() => {
canvasRef.current?.addEventListener(“wheel”, wheelHandler, {passive: false});

return () => {
canvasRef.current?.removeEventListener(“wheel”, wheelHandler);
};
}, [camera]);

useEffect(() => {
if (bestMicroPrice && precision) {
const zoomedTickSize = parseFloat(tickSize) * Math.pow(10, precision.price) * zoom;
setZoomedBestMicroPrice({
buy: parseInt((Math.floor(bestMicroPrice.buy / zoomedTickSize) * zoomedTickSize).toFixed(0)),
sell: parseInt((Math.ceil(bestMicroPrice.sell / zoomedTickSize) * zoomedTickSize).toFixed(0)),
});
}
}, [bestMicroPrice]);

useEffect(() => {
if (null !== animationFrameId) {
cancelAnimationFrame(animationFrameId);
}

setAnimationFrameId(requestAnimationFrame(draw));
}, [cup, symbol, camera]);

useEffect(() => {
setDpiScale(Math.ceil(window.devicePixelRatio));
}, [window.devicePixelRatio]);

useEffect(() => {
if (!size) {
return;
}

setCanvasSize({
width: Math.floor(size.width) * dpiScale,
height: Math.floor(size.height) * dpiScale,
});
}, [dpiScale, size]);

useEffect(() => {
if (0 === camera) {
centralizeCamera();
}
}, [zoomedBestMicroPrice]);

useEffect(() => {
if (symbol) {
dispatch(setCupPair(symbol.toUpperCase()));
dispatch(setCupCamera(0));
}
}, [symbol]);

useEffect(() => {
if (readyState === ReadyState.OPEN && null !== pair) {
cupSubscribe(pair, camera, zoom, rowCount);
}

return () => {
if (pair != null) {
cupUnsubscribe(pair);
}
};
}, [pair, camera, zoom, rowCount, readyState]);

return <div ref={containerRef} className={styles.canvasWrapper}>
<div className={styles.controls}>
<IconButton onClick={zoomSub} size=“small”>
<RemoveRounded />
</IconButton>
<span>x{zoom}</span>
<IconButton onClick={zoomAdd} size=“small”>
<AddRounded />
</IconButton>
</div>
<canvas
ref={canvasRef}
className={styles.canvas}
width={canvasSize?.width}
height={canvasSize?.height}
/>
</div>;
};

export default Cup;

import {CanvasSize} from “./Cup”;
import cupOptions, {drawRoundedRect} from “./Cup.options”;
import cupTools from “./Cup.tools”;
import {CupItem} from “…/…/…/hooks/rustWsServer”;

const cupDrawer = {
clear: (ctx: CanvasRenderingContext2D, size: CanvasSize) => {
ctx.clearRect(0, 0, size.width, size.height);
},
drawCell: (
ctx: CanvasRenderingContext2D,
cupItem: CupItem,
maxVolume: number,
isBestPrice: boolean,
yPosition: number,
cellHeight: number,
fullWidth: number,
dpiScale: number,
darkMode: boolean,
) => {
ctx.beginPath();
ctx.strokeStyle = cupOptions(darkMode).border.color;
ctx.lineWidth = cupOptions(darkMode).border.width;
ctx.fillStyle = cupTools.getCellBackground(cupItem.side, isBestPrice, darkMode);
// @ts-ignore
ctx.roundRect(
0,
yPosition,
cupTools.getCellWidth(fullWidth, cupItem.quantity, maxVolume, isBestPrice),
cellHeight,
cupOptions(darkMode).border.radius * dpiScale,
);
ctx.fill();
ctx.stroke();
},
drawFill: (
ctx: CanvasRenderingContext2D,
cupItem: CupItem,
maxVolume: number,
yPosition: number,
cellHeight: number,
fullWidth: number,
dpiScale: number,
darkMode: boolean,
) => {
ctx.beginPath();
ctx.strokeStyle = “#ffffff00”;
ctx.lineWidth = cupOptions().border.width;
ctx.fillStyle = cupItem.side === “Buy”
? cupOptions(darkMode).bestBuy.fillColor
: cupOptions(darkMode).bestSell.fillColor;
ctx.roundRect(
0,
yPosition,
cupTools.getCellWidth(fullWidth, cupItem.quantity, maxVolume, false),
cellHeight,
cupOptions().border.radius * dpiScale,
);
ctx.fill();
ctx.stroke();
},
drawPrice: (
ctx: CanvasRenderingContext2D,
price: string,
side: string,
isBestPrice: boolean,
yPosition: number,
cellHeight: number,
fullWidth: number,
dpiScale: number,
darkMode: boolean,
) => {
const {width: textWidth} = ctx.measureText(price);
ctx.textBaseline = “middle”;
ctx.font = ${cupOptions().font.weight} ${cupOptions().font.sizePx * dpiScale}px ${cupOptions().font.family};
ctx.fillStyle = cupTools.getPriceColor(side, isBestPrice, darkMode);
ctx.fillText(
price,
fullWidth - cupOptions().cell.paddingX * dpiScale - textWidth,
yPosition + cellHeight / 2,
);
},
drawQuantity: (
ctx: CanvasRenderingContext2D,
quantity: string,
side: string,
isBestPrice: boolean,
yPosition: number,
cellHeight: number,
fullWidth: number,
dpiScale: number,
darkMode: boolean,
) => {
ctx.textBaseline = “middle”;
ctx.fillStyle = cupTools.getQuantityColor(side, isBestPrice, darkMode);
ctx.fillText(
quantity,
cupOptions().cell.paddingX * dpiScale,
yPosition + cellHeight / 2,
);
},
};

export default cupDrawer;

const cupOptions = (darkMode?: boolean) => ({
sell: {
backgroundColor: darkMode ? “#4A0B09” : “#F3C9CE”,
priceColor: darkMode ? “#fff” : “#B41C18”,
quantityColor: darkMode ? “#CA1612” : “#191919”,
},
bestSell: {
backgroundColor: darkMode ? “#4A0B09” : “#d0706d”,
fillColor: darkMode ? “#CA1612” : “#a5120e”,
priceColor: “#FFFFFF”,
quantityColor: “#FFFFFF”,
},
buy: {
backgroundColor: darkMode ? “#0f3b2b” : “#CAD7CD”,
priceColor: darkMode ? “#fff” : “#006943”,
quantityColor: darkMode ? “#00923A” : “#191919”,
},
bestBuy: {
backgroundColor: darkMode ? “#006943” : “#4d967b”,
fillColor: darkMode ? “#00923A” : “#005737”,
priceColor: “#FFFFFF”,
quantityColor: “#FFFFFF”,
},
font: {
family: “Mont”,
sizePx: 11,
weight: 400,
},
border: {
width: 1,
color: darkMode ? “#191919” : “#ffffff”,
radius: 6,
},
cell: {
paddingX: 4,
defaultHeight: 16,
},
});

export default cupOptions;


отрисовать в drawFill прямоугольник(Rect) вместо roundRect и потом скруглить углы цветом светлой/темной темы
думаю нужно путь только построить немного иначе и в два патча (beginPath / closePath)

Первым слоем рисуется обычный прямоугольник со скруглением. drawCell
Вторым слоем прямоугольник без скругления. drawFill
Третьим слоем рисуются скругления (розовые на скрине) белого цвета для светлой темы и темного для темной.