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
Третьим слоем рисуются скругления (розовые на скрине) белого цвета для светлой темы и темного для темной.