const TradingCup = () => {
  const cupWorkerRef = useRef<Worker>();
  const orderFeedWorkerRef = useRef<Worker>();
  const symbol = useSelector((state: AppState) => state.screenerSlice.symbol);
  const [settings, setSettings] = useState<SettingsCup>({
    timeout: "40",
    aggregation: true,
    maxVolume: window.localStorage.getItem(`cup-volume-${symbol}`) || "1000",
    isFixedVolume: null !== window.localStorage.getItem(`cup-volume-${symbol}`),
    minQuantity: window.localStorage.getItem(`order-feed-min-qty-${symbol}`) || "0",
    dollarsVolume: null !== window.localStorage.getItem(`cup-dollars-volume-${symbol}`),
  });
  const [showSettings, setShowSettings] = useState(false);

  useEffect(() => {
    const channel = new MessageChannel();
    cupWorkerRef.current?.postMessage({type: "set_port", port: channel.port1}, [channel.port1]);
    orderFeedWorkerRef.current?.postMessage({type: "set_port", port: channel.port2}, [channel.port2]);
  }, []);

  useEffect(() => {
    setSettings({
      ...settings,
      maxVolume: window.localStorage.getItem(`cup-volume-${symbol}`) || "1000",
      isFixedVolume: null !== window.localStorage.getItem(`cup-volume-${symbol}`),
      minQuantity: window.localStorage.getItem(`order-feed-min-qty-${symbol}`) || "0",
      dollarsVolume: null !== window.localStorage.getItem(`cup-dollars-volume-${symbol}`),
    });
  }, [symbol]);

  return <Stack direction="row" height="100%">

    <div className={styles.Cup}>
      <Cup
        symbol={symbol}
        workerRef={cupWorkerRef}
        settings={settings}
        setShowSettings={setShowSettings}
      />
    </div>

    <SettingsPopupCup
      settings={settings}
      setSettings={setSettings}
      showSettings={showSettings}
      setShowSettings={setShowSettings}
    />
  </Stack>;
};

export default TradingCup;

export type CupItem = {
  ask?: number;
  bid?: number;
};


const Cup = ({workerRef, symbol, settings, setShowSettings}: CupProps) => {
  const cupParams = useSelector((state: AppState) => state.cupSlice);
  const [dpiScale, setDpiScale] = useState(Math.ceil(window.devicePixelRatio));
  const [canvasSize, setCanvasSize] = useState<CanvasSize>({height: 0, width: 0});
  const containerRef = useRef<HTMLDivElement|null>(null);
  const canvasRef = useRef<HTMLCanvasElement|null>(null);
  const [zoom, setZoom] = useState(1);
  const [isLoaded, setIsLoaded] = useState(false);
  const size = useComponentResizeListener(canvasRef);
  const darkMode = useTheme().palette.mode === "dark";
  const dispatch = useDispatch();
  const cupSubscribe = useCallback(async(pair: string, zoom: number) => {
    workerRef.current?.postMessage(JSON.stringify({type: "subscribe", pair, zoom}));
  }, []);

  const cupUnsubscribe = useCallback(async(pair: string) => {
    workerRef.current?.postMessage(JSON.stringify({type: "unsubscribe", pair}));
  }, []);

  const sendSettings = () => {
    workerRef.current?.postMessage(JSON.stringify({
      type: "change_max_volume",
      isFixed: settings.isFixedVolume,
    }));
    workerRef.current?.postMessage(JSON.stringify({
      type: "change_publisher_timeout",
      value: parseInt(settings.timeout || "0") < 40 ? 40 : parseInt(settings.timeout),
    }));
  };

  const wheelHandler = (e: WheelEvent) => {
    e.preventDefault();
    workerRef.current?.postMessage(JSON.stringify({type: e.deltaY < 0 ? "camera_up" : "camera_down"}));
  };
  useEffect(() => {
    workerRef.current = new Worker(new URL("/workers/cup-builder.ts", import.meta.url));
    canvasRef.current?.addEventListener("wheel", wheelHandler, {passive: false});
    sendSettings();

    return () => {
      workerRef.current?.terminate();
      canvasRef.current?.removeEventListener("wheel", wheelHandler);
    };
  }, [settings.dollarsVolume]);

  useEffect(() => {
    if (!workerRef.current) return;
    let animationFrameId: number|null = null;

    workerRef.current.onmessage = (event: MessageEvent<{
      type: string,
      camera: number,
      aggregation: number,
      bestBidPrice: number,
      bestAskPrice: number,
      maxVolume: number,
      pricePrecision: number,
      quantityPrecision: number,
      priceStep: number,
      cup: {[key: number]: CupItem},
      rowsCount: number,
    }>) => {
      if (event?.data?.type === "update_cup") {
        if (null !== animationFrameId) {
          cancelAnimationFrame(animationFrameId);
        }
        
        console.log(event?.data); // aggregation: 1
// bestAskPrice: 260718
// bestBidPrice: 260717
// camera: 260718
// cup: {260685: {…}, 260686: {…}, 260687: {…}, 260688: {…}, 260689: {…}, 260690: {…}, 260691: {…}, 260692: {…}, 260693: {…}, 260694: {…}, 260695: {…}, 260696: {…}, 260697: {…}, 260698: {…}, 260699: {…}, 260700: {…}, 260701: {…}, 260702: {…}, 260703: {…}, 260704: {…}, 260705: {…}, 260706: {…}, 260707: {…}, 260708: {…}, 260709: {…}, 260710: {…}, 260711: {…}, 260712: {…}, 260713: {…}, 260714: {…}, 260715: {…}, 260716: {…}, 260717: {…}, 260718: {…}, 260719: {…}, 260720: {…}, 260721: {…}, 260722: {…}, 260723: {…}, 260724: {…}, 260725: {…}, 260726: {…}, 260727: {…}, 260728: {…}, 260729: {…}, 260730: {…}, 260731: {…}, 260732: {…}, 260733: {…}, 260734: {…}, 260735: {…}, 260736: {…}, 260737: {…}, 260738: {…}, 260739: {…}, 260740: {…}, 260741: {…}, 260742: {…}, 260743: {…}, 260744: {…}, 260745: {…}, 260746: {…}, 260747: {…}, 260748: {…}, 260749: {…}}
// maxVolume: 0
// pricePrecision: 1
// priceStep: 1
// quantityPrecision: 3
// rowsCount: 64
// type: "update_cup"
 animationFrameId = requestAnimationFrame(() => {
          const context = canvasRef.current?.getContext("2d");
          const zoomedTickSize = event.data.priceStep * event.data.aggregation;

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

            if (event.data.rowsCount !== rowsOnScreenCount) {
              workerRef.current?.postMessage(JSON.stringify({type: "change_rows_count", value: rowsOnScreenCount}));
            }

            cupDrawer.clear(context, canvasSize);

            if (cupParams.rowCount !== rowsOnScreenCount
              || cupParams.cellHeight !== realCellHeight
              || cupParams.aggregation !== event.data.aggregation
            ) {
              dispatch(setCupParams({
                aggregation: event.data.aggregation,
                rowCount: rowsOnScreenCount,
                cellHeight: realCellHeight,
                pricePrecision: event.data.pricePrecision,
                priceStep: event.data.priceStep,
                quantityPrecision: event.data.quantityPrecision,
              }));
            }

            if (event.data.camera === 0 && isLoaded) {
              setIsLoaded(false);
            }

            if (event.data.camera > 0 && !isLoaded) {
              setIsLoaded(true);
            }

            if (event.data.camera !== 0) {
              cupDrawer.draw(
                context,
                canvasSize,
                dpiScale,
                event.data.bestBidPrice,
                event.data.bestAskPrice,
                event.data.maxVolume,
                event.data.pricePrecision,
                event.data.quantityPrecision,
                event.data.priceStep,
                event.data.aggregation,
                rowsOnScreenCount,
                event.data.camera,
                realCellHeight,
                {
                  buy: parseInt((Math.floor(event.data.bestBidPrice / zoomedTickSize) * zoomedTickSize).toFixed(0)),
                  sell: parseInt((Math.ceil(event.data.bestAskPrice / zoomedTickSize) * zoomedTickSize).toFixed(0)),
                },
                darkMode,
                event.data.cup,
              );
            }
          }
        });
      }
    };

    return () => {
      if (null !== animationFrameId) {
        cancelAnimationFrame(animationFrameId);
      }
    };
  }, [workerRef.current, canvasSize, darkMode, dpiScale, isLoaded]);

  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(() => {
    cupSubscribe(symbol.toUpperCase(), zoom);

    return () => {
      cupUnsubscribe(symbol.toUpperCase());
    };
  }, [symbol, zoom]);

  useEffect(() => {
    if (settings.isFixedVolume) {
      window.localStorage.setItem(`cup-volume-${symbol}`, settings.maxVolume);
    } else {
      window.localStorage.removeItem(`cup-volume-${symbol}`);
    }

    sendSettings();
  }, [settings]);

  return <div ref={containerRef} className={styles.canvasWrapper}>
 <canvas
      ref={canvasRef}
      className={[styles.canvas, isLoaded ? "" : styles.loading].join(" ")}
      width={canvasSize?.width}
      height={canvasSize?.height}
    />
  </div>;
};

export default Cup;


const cupDrawer = {
  clear: (ctx: CanvasRenderingContext2D, size: CanvasSize) => {
    ctx.clearRect(0, 0, size.width, size.height);
  },
  draw: (
    ctx: CanvasRenderingContext2D,
    size: CanvasSize,
    dpiScale: number,
    bestBidPrice: number,
    bestAskPrice: number,
    maxVolume: number,
    pricePrecision: number,
    quantityPrecision: number,
    priceStep: number,
    zoom: number,
    rowCount: number,
    camera: number,
    cellHeight: number,
    zoomedBestMicroPrice: any,
    darkMode: boolean,
    cup: {[key: number]: CupItem},
  ) => {
    const zoomedTickSize = priceStep * zoom;
    const startMicroPrice = camera + rowCount / 2 * zoomedTickSize - zoomedTickSize;
    const quantityDivider = Math.pow(10, quantityPrecision);

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

      if (microPrice < 0) continue;

      const item = cup[microPrice] || {};
      let side = "undefined" !== typeof item?.bid
        ? "bid"
        : "undefined" !== typeof item?.ask ? "ask" : "";

      if ("" === side || (!item?.bid && !item.ask)) {
        side = (microPrice >= (zoomedBestMicroPrice?.buy || zoomedBestMicroPrice?.sell || 0) ? "ask" : "bid");
      }

      const yPosition = cellHeight * index;
      const quantity = (side === "bid"
        ? item?.bid || 0
        : item?.ask || 0) / quantityDivider;
      const isBestPrice = cupTools.isBestPrice(
        side,
        microPrice,
        zoomedBestMicroPrice,
      );

      if ((microPrice / zoomedTickSize) % 5 === 0) {
        ctx.beginPath();
        ctx.strokeStyle = darkMode ? "#232425" : "#dddedf";
        ctx.lineWidth = Math.max(1, dpiScale);
        ctx.moveTo(0, yPosition);
        ctx.lineTo(size.width, yPosition);
        ctx.stroke();
      }

      cupDrawer.drawCell(
        ctx,
        quantity,
        side,
        maxVolume,
        isBestPrice,
        yPosition,
        cellHeight,
        size.width,
        dpiScale,
        darkMode
      );

      if (isBestPrice) {
        cupDrawer.drawFill(
          ctx,
          quantity,
          side,
          maxVolume,
          yPosition,
          cellHeight,
          size.width,
          dpiScale,
          darkMode
        );
      }

      cupDrawer.drawPrice(
        ctx,
        cupTools.microPriceToReal(microPrice, pricePrecision),
        side,
        isBestPrice,
        yPosition,
        cellHeight,
        size.width,
        dpiScale,
        darkMode
      );

      cupDrawer.drawQuantity(
        ctx,
        cupTools.abbreviateNumber(quantity),
        side,
        isBestPrice,
        yPosition,
        cellHeight,
        size.width,
        dpiScale,
        darkMode
      );
    }
  },
  drawCell: (
    ctx: CanvasRenderingContext2D,
    quantity: number,
    side: string,
    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(side, isBestPrice, darkMode);
    // @ts-ignore
    ctx.roundRect(
      0,
      yPosition,
      cupTools.getCellWidth(fullWidth, quantity, maxVolume, isBestPrice),
      cellHeight,
      cupOptions(darkMode).border.radius * dpiScale,
    );
    ctx.fill();
    ctx.stroke();
  },
  drawFill: (
    ctx: CanvasRenderingContext2D,
    quantity: number,
    side: string,
    maxVolume: number,
    yPosition: number,
    cellHeight: number,
    fullWidth: number,
    dpiScale: number,
    darkMode: boolean,
  ) => {
    const cornerRadius = cupOptions().border.radius * dpiScale;
    const x = 0;
    const y = yPosition;
    const height = cellHeight;
    const fillColor = side === "bid"
      ? cupOptions(darkMode).bestBuy.fillColor
      : cupOptions(darkMode).bestSell.fillColor;

    ctx.beginPath();
    ctx.rect(
      0,
      y + 1,
      cupTools.getCellWidth(fullWidth, quantity, maxVolume, false),
      height - 2,
    );

    ctx.fillStyle = fillColor;
    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;


Когда dollarsVolume true - перемножать quantity на price при отрисовке строк стакана.