export interface CanvasSize {
width: number;
height: number;
}

const Cup = ({workerRef}: CupProps) => {
const symbol = useSelector((state: AppState) => state.screenerSlice.symbol);
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 [settings, setSettings] = useState({
timeout: “40”,
maxVolume: window.localStorage.getItem(cup-volume-${symbol}) || “1000”,
isFixedVolume: null !== window.localStorage.getItem(cup-volume-${symbol}),
});
const [showSettings, setShowSettings] = 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,
maxVolume: parseInt(settings.maxVolume || “0”) < 0 ? 1 : parseInt(settings.maxVolume),
}));
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”}));
};

const zoomAdd = () =>setZoom(newZoom);
const zoomSub = () => setZoom(-10);

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);
};
}, []);

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

workerRef.current.onmessage = (event: MessageEvent<
{......
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(() => {
setSettings({
…settings,
maxVolume: window.localStorage.getItem(cup-volume-${symbol}) || “1000”,
isFixedVolume: null !== window.localStorage.getItem(cup-volume-${symbol}),
});
}, [symbol]);

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}
/>

<Dialog open={showSettings} onClose={() => setShowSettings(false)} fullWidth maxWidth=“xs”>
<DialogTitle>Настройки стакана</DialogTitle>
<DialogContent dividers>
<Box
component=“form”
autoComplete=“off”
onSubmit={() => {}}
>
<TextField
fullWidth
label=“Таймаут обновления стакана, мс.”
variant=“standard”
margin=“normal”
value={settings.timeout}
onChange={e => {
setSettings({…settings, timeout: e.target.value});
}}
/>
<Typography fontSize=“xx-small”>Минимальный таймаут - 40 мс.</Typography>

<FormControlLabel
control={<Checkbox value=“isFixedVolume” checked={settings.isFixedVolume} color=“primary” />}
label=“Фиксированный объем.”
onChange={() => setSettings({…settings, isFixedVolume: !settings.isFixedVolume})}
/>

{settings.isFixedVolume && (
<TextField
fullWidth
label=“Объем стакана.”
variant=“standard”
margin=“normal”
value={settings.maxVolume}
onChange={e => {
setSettings({…settings, maxVolume: e.target.value});
}}
/>
)}
</Box>
</DialogContent>
</Dialog>
</div>;
};

export default Cup;




const OrderFeed = ({workerRef}: OrderFeedProps) => {
const symbol = useSelector((state: AppState) => state.screenerSlice.symbol);
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 [settings, setSettings] = useState({
timeout: “40”,
aggregation: true,
minQuantity: window.localStorage.getItem(order-feed-min-qty-${symbol}) || “0”,
});
const [showSettings, setShowSettings] = useState(false);
const size = useComponentResizeListener(canvasRef);
const theme = useTheme();

const draw = (trades: TradeItem[], camera: number) => {
if (null === canvasRef || !Array.isArray(trades)) return;

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

if (context) {
orderFeedDrawer.clear(context, canvasSize);
orderFeedDrawer.drawLine(
context,
canvasSize,
trades,
camera,
cupParams.aggregatedPriceStep,
cupParams.cellHeight,
cupParams.quantityDivider,
!settings.minQuantity ? 0 : parseFloat(settings.minQuantity),
dpiScale,
theme,
);
orderFeedDrawer.drawChain(
context,
canvasSize,
trades,
camera,
cupParams.aggregatedPriceStep,
cupParams.cellHeight,
cupParams.quantityDivider,
!settings.minQuantity ? 0 : parseFloat(settings.minQuantity),
dpiScale,
theme,
);
}
};

const wheelHandler = (event: WheelEvent) => {
event.preventDefault();
const scrollAmount = event.deltaY;

if (containerRef.current) {
containerRef.current.scrollLeft += scrollAmount;
}
};

const tradeSubscribe = useCallback(async(symbol: string) => {
workerRef.current?.postMessage(JSON.stringify({type: “subscribe”, symbol}));
}, []);

const tradeUnsubscribe = useCallback(async(symbol: string) => {
workerRef.current?.postMessage(JSON.stringify({type: “unsubscribe”, symbol}));
}, []);

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

workerRef.current.onmessage = (event: MessageEvent<any>) => {
if (event.data.type === “update_trades”) {
if (null !== animationFrameId) {
cancelAnimationFrame(animationFrameId);
}

animationFrameId = requestAnimationFrame(() => draw(event.data.trades, event.data.camera));
}
};

return () => {
if (null !== animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};
}, [workerRef.current, canvasRef.current, canvasSize, symbol, cupParams, theme, settings.minQuantity]);

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(() => {
tradeSubscribe(symbol);
setSettings(state => ({
…state,
minQuantity: window.localStorage.getItem(order-feed-min-qty-${symbol}) || “0”,
}));

return () => {
tradeUnsubscribe(symbol);
};
}, [symbol, workerRef.current]);

useEffect(() => {
containerRef.current?.scroll(800, 0);
containerRef.current?.addEventListener(“wheel”, wheelHandler, {passive: false});
workerRef.current = new Worker(new URL(”/workers/order-feed-builder.ts", import.meta.url));

return () => {
containerRef.current?.removeEventListener(“wheel”, wheelHandler);
workerRef.current?.terminate();
};
}, []);

useEffect(() => {
workerRef.current?.postMessage(JSON.stringify({
type: “change_publisher_timeout”,
value: parseInt(settings.timeout || “0”) < 40 ? 40 : parseInt(settings.timeout),
}));
workerRef.current?.postMessage(JSON.stringify({
type: “change_aggregation”,
value: settings.aggregation,
}));
window.localStorage.setItem(order-feed-min-qty-${symbol}, settings.minQuantity);
}, [settings]);

return <Box ref={containerRef} sx={{
scrollbarWidth: “thin”,
“&::-webkit-scrollbar-track”: {
background: theme => theme.palette.background.paper,
},
“&::-webkit-scrollbar-thumb”: {
backgroundColor: theme => theme.palette.grey[300],
border: theme => 3px solid ${theme.palette.white.main},
},
}} className={${styles.canvasWrapper} scroll-block}>
<div className={styles.controls}>
<IconButton onClick={() => setShowSettings(true)} size=“small”>
<SettingsRounded
// @ts-ignore
fontSize=“xx-small”
/>
</IconButton>
</div>
<canvas
ref={canvasRef}
className={styles.canvas}
width={canvasSize?.width}
height={canvasSize?.height}
/>

<Dialog open={showSettings} onClose={() => setShowSettings(false)} fullWidth maxWidth=“xs”>
<DialogTitle>Настройки списка ордеров</DialogTitle>
<DialogContent dividers>
<Box
component=“form”
autoComplete=“off”
onSubmit={() => {}}
>
<TextField
fullWidth
label=“Минимальный отображаемый объем”
variant=“standard”
margin=“normal”
value={settings.minQuantity}
onChange={e => {
setSettings({…settings, minQuantity: e.target.value});
}}
/>

<TextField
fullWidth
label=“Таймаут обновления стакана, мс.”
variant=“standard”
margin=“normal”
value={settings.timeout}
onChange={e => {
setSettings({…settings, timeout: e.target.value});
}}
/>
<Typography fontSize=“xx-small”>Минимальный таймаут - 40 мс.</Typography>

<FormControlLabel
control={<Checkbox value=“isFixedVolume” checked={settings.aggregation} color=“primary” />}
label=“Агрегация сделок.”
onChange={() => setSettings({…settings, aggregation: !settings.aggregation})}
/>

</Box>
</DialogContent>
</Dialog>
</Box>;
};

export default OrderFeed;

const TradingCup = () => {
const cupWorkerRef = useRef<Worker>();
const orderFeedWorkerRef = useRef<Worker>();

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]);
}, []);

return <Stack direction=“row” height=“100%”>
<div className={styles.OfferFeed}>
<OrderFeed workerRef={orderFeedWorkerRef} />
</div>
<div className={styles.Cup}>
<Cup workerRef={cupWorkerRef} />
</div>
</Stack>;
};

export default TradingCup;


нужно настройки OrderFeed и Cup объединить в одно окно и кнопку. Нужно вынести этот попап в TradingCup компонент. Оттуда есть доступ к обоим воркерам.