
Представим, что вам поставили задачу создать график, аналогичный представленному на платформе tradingview.com. Такие графики используются для отображения информации, например, в торговых платформах. Решение задачи с нуля будет достаточно сложным, поэтому сначала стоит проанализировать уже имеющиеся наработки.
Если провести поиск готовых решений в Интернете, выяснится, что графических библиотек на React множество. Но единственный, уникальный в своем роде проект, позволяющий с минимальными временными затратами решить поставленную задачу, – проект React Stockcharts. Эта библиотека написана на React и уже включает в себя поддержку некоторых индикаторов и элементов рисования, а также динамическую загрузку данных с отрисовкой баров. Другие графические библиотеки не имеют данного функционала и требуют значительной доработки. Нам же остается только расширить React Stockcharts добавлением новых индикаторов и элементов рисования. Цель этой статьи – показать, как расширить функционал библиотеки и добавить новые элементы.
React Stockcharts использует d3js, поддерживает рисование на canvas и SVG, элементы библиотеки структурированы и разделены на отдельные компоненты. Это облегчает понимание логики работы библиотеки. При разработке использовалась версия React 16.8.6, для сборки проекта используется babel и webpack.
С чего начать?
Первое, что нужно сделать, – скачать исходный код библиотеки с github. Проинсталлируйте зависимости, выполнив npm install —save react-stockcharts, и запустите проект командой npm run watch.
Структура папок подсказывает, как организован проект. Все элементы графика разделены на отдельные компоненты и называются соответственно.

Создание нового индикатора
Добавление индикатора рассмотрим на примере Money Flow Index. Для создания нового индикатора нужно выполнить следующие действия:
1. Создать файл mfi.js в папке indicator. В нем осуществляется привязка алгоритма, способа рисования и других свойств к индикатору.
import { rebind, merge } from "../utils"; import { mfi } from "../calculator"; import baseIndicator from "./baseIndicator"; const ALGORITHM_TYPE = "MFI"; export default function() { const base = baseIndicator() .type(ALGORITHM_TYPE) .accessor(d => d.mfi); const underlyingAlgorithm = mfi(); const mergedAlgorithm = merge() .algorithm(underlyingAlgorithm) .merge((datum, indicator) => { datum.mfi = indicator; }); const indicator = function(data, options = { merge: true }) { if (options.merge) { if (!base.accessor()) throw new Error(`Set an accessor to ${ALGORITHM_TYPE} before calculating`); return mergedAlgorithm(data); } return underlyingAlgorithm(data); }; rebind(indicator, base, "id", "accessor", "stroke", "fill", "echo", "type"); rebind(indicator, underlyingAlgorithm, "undefinedLength"); rebind(indicator, underlyingAlgorithm, "options"); rebind(indicator, mergedAlgorithm, "merge", "skipUndefined"); return indicator; }
2. Создать файл mfi.js в папке calculator. Здесь реализован математический алгоритм для индикатора.
import { mean } from "d3-array"; import { slidingWindow } from "../utils"; import { MFI as defaultOptions } from "./defaultOptionsForComputation"; export default function() { let options = defaultOptions; function calculator(data) { const { windowSize } = options; let typical_price, typical_price_privious, money_flow, flow_ratio, flow_index, val_positive_minus, val_negative_minus, money_flow_privious; let val_positive = 0, val_negative = 0, ind = 0; const arr_positive = [], arr_negative = []; return data.map(function(d,i){ if(i === 0){ typical_price_privious = (d.high + d.low + d.close) / 3; ind++; } else { typical_price = (d.high + d.low + d.close) / 3; money_flow = typical_price * d.volume; if(typical_price >= typical_price_privious){ val_positive += money_flow; arr_positive.push(money_flow); arr_negative.push(0); } else { val_negative += money_flow; arr_negative.push(money_flow); arr_positive.push(0); } if(ind >= windowSize ){ if(i !== windowSize){ val_positive = val_positive - val_positive_minus; val_negative = val_negative - val_negative_minus; } val_positive_minus = arr_positive[0]; val_negative_minus = arr_negative[0]; arr_positive.shift(); arr_negative.shift(); } typical_price_privious = typical_price; money_flow_privious = money_flow; if(ind >= windowSize){ flow_ratio = val_positive / val_negative; flow_index = 100 - (100 / (1 + flow_ratio)); ind++; return flow_index; } else { ind++; return undefined; } } }); } calculator.undefinedLength = function() { const { windowSize } = options; return windowSize - 1; }; calculator.options = function(x) { if (!arguments.length) { return options; } options = { ...defaultOptions, ...x }; return calculator; }; return calculator; }
3. Файл calculator/defaultOptionsForComputation.js содержит значение параметров по умолчанию, для вычислений и для рисования графика.
... export const MFI = { source: d => ({volume: d.volume, high: d.high, low: d.low}), // "high", "low", "open", "close" sourcePath: "volume/high/low", windowSize: 10, }; ...
Для данного индикатора используется стандартный tooltip из tooltip/MovingAverageTooltip.js. Для рисования линии индикатора используется компонент LineSeries из series/LineSeries.js. Более сложные индикаторы состоят из комбинации отдельных элементов LineSeries, CircleMarker и т.д.
Результат работы индикатора MFI представлен на рисунке:

Добавления элемента рисования.
В примере ниже в библиотеку добавляется элемент рисования – прямоугольник.
1. Создадим файл RectangleSimple.js в папке interactive/components. В данном файле реализован алгоритм рисования прямоугольника, определяется, когда курсор мыши находится над элементом, свойство isHovering.
import React, { Component } from "react"; import PropTypes from "prop-types"; import GenericChartComponent from "../../GenericChartComponent"; import { getMouseCanvas } from "../../GenericComponent"; import { isDefined, noop, hexToRGBA, getStrokeDasharray, strokeDashTypes, } from "../../utils"; class RectangleSimple extends Component { constructor(props) { super(props); this.renderSVG = this.renderSVG.bind(this); this.drawOnCanvas = this.drawOnCanvas.bind(this); this.isHover = this.isHover.bind(this); } isHover(moreProps) { const { tolerance, onHover } = this.props; if (isDefined(onHover)) { const { x1Value, x2Value, y1Value, y2Value, type } = this.props; const { mouseXY, xScale } = moreProps; const { chartConfig: { yScale } } = moreProps; const hovering = isHovering({ x1Value, y1Value, x2Value, y2Value, mouseXY, type, tolerance, xScale, yScale, }); // console.log("hovering ->", hovering); return hovering; } return false; } drawOnCanvas(ctx, moreProps) { const { stroke, strokeWidth, strokeOpacity, strokeDasharray, type, fill, fillOpacity, isFill } = this.props; const { x1, y1, x2, y2 } = helper(this.props, moreProps); const width = x2 - x1; const height = y2 - y1; ctx.beginPath(); ctx.rect(x1, y1, width, height); ctx.stroke(); if(isFill){ ctx.fillStyle = hexToRGBA(fill, fillOpacity); ctx.fill(); } } renderSVG(moreProps) { const { stroke, strokeWidth, strokeOpacity, strokeDasharray } = this.props; const lineWidth = strokeWidth; const { x1, y1, x2, y2 } = helper(this.props, moreProps); return ( ); } render() { const { selected, interactiveCursorClass } = this.props; const { onDragStart, onDrag, onDragComplete, onHover, onUnHover } = this.props; return ; } } export function isHovering2(start, end, [mouseX, mouseY], tolerance) { const m = getSlope(start, end); if (isDefined(m)) { const b = getYIntercept(m, end); const y = m * mouseX + b; return (mouseY < y + tolerance) && mouseY > (y - tolerance) && mouseX > Math.min(start[0], end[0]) - tolerance && mouseX < Math.max(start[0], end[0]) + tolerance; } else { return mouseY >= Math.min(start[1], end[1]) && mouseY <= Math.max(start[1], end[1]) && mouseX < start[0] + tolerance && mouseX > start[0] - tolerance; } } export function isHovering({ x1Value, y1Value, x2Value, y2Value, mouseXY, type, tolerance, xScale, yScale, }) { const line = generateLine({ type, start: [x1Value, y1Value], end: [x2Value, y2Value], xScale, yScale, }); const start = [xScale(line.x1), yScale(line.y1)]; const end = [xScale(line.x2), yScale(line.y2)]; const m = getSlope(start, end); const [mouseX, mouseY] = mouseXY; if (isDefined(m)) { const b = getYIntercept(m, end); const y = m * mouseX + b; return mouseY < (y + tolerance) && mouseY > (y - tolerance) && mouseX > Math.min(start[0], end[0]) - tolerance && mouseX < Math.max(start[0], end[0]) + tolerance; } else { return mouseY >= Math.min(start[1], end[1]) && mouseY <= Math.max(start[1], end[1]) && mouseX < start[0] + tolerance && mouseX > start[0] - tolerance; } } function helper(props, moreProps) { const { x1Value, x2Value, y1Value, y2Value, type } = props; const { xScale, chartConfig: { yScale } } = moreProps; const modLine = generateLine({ type, start: [x1Value, y1Value], end: [x2Value, y2Value], xScale, yScale, }); const x1 = xScale(modLine.x1); const y1 = yScale(modLine.y1); const x2 = xScale(modLine.x2); const y2 = yScale(modLine.y2); return { x1, y1, x2, y2 }; } export function getSlope(start, end) { const m /* slope */ = end[0] === start[0] ? undefined : (end[1] - start[1]) / (end[0] - start[0]); return m; } export function getYIntercept(m, end) { const b /* y intercept */ = -1 * m * end[0] + end[1]; return b; } export function generateLine({ type, start, end, xScale, yScale }) { const m /* slope */ = getSlope(start, end); // console.log(end[0] - start[0], m) const b /* y intercept */ = getYIntercept(m, start); switch (type) { case "XLINE": return getXLineCoordinates({ type, start, end, xScale, yScale, m, b }); case "RAY": return getRayCoordinates({ type, start, end, xScale, yScale, m, b }); case "LINE": return getLineCoordinates({ type, start, end, xScale, yScale, m, b }); } } function getXLineCoordinates({ start, end, xScale, yScale, m, b }) { const [xBegin, xFinish] = xScale.domain(); const [yBegin, yFinish] = yScale.domain(); if (end[0] === start[0]) { return { x1: end[0], y1: yBegin, x2: end[0], y2: yFinish, }; } const [x1, x2] = end[0] > start[0] ? [xBegin, xFinish] : [xFinish, xBegin]; return { x1, y1: m * x1 + b, x2, y2: m * x2 + b }; } function getRayCoordinates({ start, end, xScale, yScale, m, b }) { const [xBegin, xFinish] = xScale.domain(); const [yBegin, yFinish] = yScale.domain(); const x1 = start[0]; if (end[0] === start[0]) { return { x1, y1: start[1], x2: x1, y2: end[1] > start[1] ? yFinish : yBegin, }; } const x2 = end[0] > start[0] ? xFinish : xBegin; return { x1, y1: m * x1 + b, x2, y2: m * x2 + b }; } function getLineCoordinates({ start, end }) { const [x1, y1] = start; const [x2, y2] = end; if (end[0] === start[0]) { return { x1, y1: start[1], x2: x1, y2: end[1], }; } return { x1, y1, x2, y2, }; } RectangleSimple.propTypes = { x1Value: PropTypes.any.isRequired, x2Value: PropTypes.any.isRequired, y1Value: PropTypes.any.isRequired, y2Value: PropTypes.any.isRequired, interactiveCursorClass: PropTypes.string, stroke: PropTypes.string.isRequired, strokeWidth: PropTypes.number.isRequired, strokeOpacity: PropTypes.number.isRequired, strokeDasharray: PropTypes.oneOf(strokeDashTypes), type: PropTypes.oneOf([ "XLINE", // extends from -Infinity to +Infinity "RAY", // extends to +/-Infinity in one direction "LINE", // extends between the set bounds ]).isRequired, onEdge1Drag: PropTypes.func.isRequired, onEdge2Drag: PropTypes.func.isRequired, onDragStart: PropTypes.func.isRequired, onDrag: PropTypes.func.isRequired, onDragComplete: PropTypes.func.isRequired, onHover: PropTypes.func, onUnHover: PropTypes.func, defaultClassName: PropTypes.string, r: PropTypes.number.isRequired, edgeFill: PropTypes.string.isRequired, edgeStroke: PropTypes.string.isRequired, edgeStrokeWidth: PropTypes.number.isRequired, withEdge: PropTypes.bool.isRequired, children: PropTypes.func.isRequired, tolerance: PropTypes.number.isRequired, selected: PropTypes.bool.isRequired, }; RectangleSimple.defaultProps = { onEdge1Drag: noop, onEdge2Drag: noop, onDragStart: noop, onDrag: noop, onDragComplete: noop, edgeStrokeWidth: 3, edgeStroke: "#000000", edgeFill: "#FFFFFF", r: 10, withEdge: false, strokeWidth: 1, strokeDasharray: "Solid", children: noop, tolerance: 7, selected: false, }; export default RectangleSimple;
2. Создадим файл EachRectangle.js в папке interactive/wrapper. Здесь определяются правила рисования множества прямоугольников.
import React, { Component } from "react"; import PropTypes from "prop-types"; import { ascending as d3Ascending } from "d3-array"; import { noop, strokeDashTypes } from "../../utils"; import { saveNodeType, isHover } from "../utils"; import { getXValue } from "../../utils/ChartDataUtil"; import Rectangle from "../components/Rectangle"; import ClickableCircle from "../components/ClickableCircle"; import HoverTextNearMouse from "../components/HoverTextNearMouse"; class EachRectangle extends Component { constructor(props) { super(props); this.handleEdge1Drag = this.handleEdge1Drag.bind(this); this.handleEdge2Drag = this.handleEdge2Drag.bind(this); this.handleLineDragStart = this.handleLineDragStart.bind(this); this.handleLineDrag = this.handleLineDrag.bind(this); this.handleEdge1DragStart = this.handleEdge1DragStart.bind(this); this.handleEdge2DragStart = this.handleEdge2DragStart.bind(this); this.handleDragComplete = this.handleDragComplete.bind(this); this.handleHover = this.handleHover.bind(this); this.isHover = isHover.bind(this); this.saveNodeType = saveNodeType.bind(this); this.nodes = {}; this.state = { hover: false, }; } handleLineDragStart() { const { x1Value, y1Value, x2Value, y2Value, } = this.props; this.dragStart = { x1Value, y1Value, x2Value, y2Value, }; } handleLineDrag(moreProps) { const { index, onDrag } = this.props; const { x1Value, y1Value, x2Value, y2Value, } = this.dragStart; const { xScale, chartConfig: { yScale }, xAccessor, fullData } = moreProps; const { startPos, mouseXY } = moreProps; const x1 = xScale(x1Value); const y1 = yScale(y1Value); const x2 = xScale(x2Value); const y2 = yScale(y2Value); const dx = startPos[0] - mouseXY[0]; const dy = startPos[1] - mouseXY[1]; const newX1Value = getXValue(xScale, xAccessor, [x1 - dx, y1 - dy], fullData); const newY1Value = yScale.invert(y1 - dy); const newX2Value = getXValue(xScale, xAccessor, [x2 - dx, y2 - dy], fullData); const newY2Value = yScale.invert(y2 - dy); onDrag(index, { x1Value: newX1Value, y1Value: newY1Value, x2Value: newX2Value, y2Value: newY2Value, }); } handleEdge1DragStart() { this.setState({ anchor: "edge2" }); } handleEdge2DragStart() { this.setState({ anchor: "edge1" }); } handleDragComplete(...rest) { this.setState({ anchor: undefined }); this.props.onDragComplete(...rest); } handleEdge1Drag(moreProps) { const { index, onDrag } = this.props; const { x2Value, y2Value, } = this.props; const [x1Value, y1Value] = getNewXY(moreProps); onDrag(index, { x1Value, y1Value, x2Value, y2Value, }); } handleEdge2Drag(moreProps) { const { index, onDrag } = this.props; const { x1Value, y1Value, } = this.props; const [x2Value, y2Value] = getNewXY(moreProps); onDrag(index, { x1Value, y1Value, x2Value, y2Value, }); } handleHover(moreProps) { if (this.state.hover !== moreProps.hovering) { this.setState({ hover: moreProps.hovering }); } } render() { const { x1Value, y1Value, x2Value, y2Value, type, stroke, strokeWidth, strokeOpacity, strokeDasharray, r, edgeStrokeWidth, edgeFill, edgeStroke, edgeInteractiveCursor, lineInteractiveCursor, hoverText, selected, onDragComplete, } = this.props; const { enable: hoverTextEnabled, selectedText: hoverTextSelected, text: hoverTextUnselected, ...restHoverTextProps } = hoverText; const { hover, anchor } = this.state; return ; } } export function getNewXY(moreProps) { const { xScale, chartConfig: { yScale }, xAccessor, plotData, mouseXY } = moreProps; const mouseY = mouseXY[1]; const x = getXValue(xScale, xAccessor, mouseXY, plotData); const [small, big] = yScale.domain().slice().sort(d3Ascending); const y = yScale.invert(mouseY); const newY = Math.min(Math.max(y, small), big); return [x, newY]; } EachRectangle.propTypes = { x1Value: PropTypes.any.isRequired, x2Value: PropTypes.any.isRequired, y1Value: PropTypes.any.isRequired, y2Value: PropTypes.any.isRequired, index: PropTypes.number, type: PropTypes.oneOf([ "XLINE", // extends from -Infinity to +Infinity "RAY", // extends to +/-Infinity in one direction "LINE", // extends between the set bounds ]).isRequired, onDrag: PropTypes.func.isRequired, onEdge1Drag: PropTypes.func.isRequired, onEdge2Drag: PropTypes.func.isRequired, onDragComplete: PropTypes.func.isRequired, onSelect: PropTypes.func.isRequired, onUnSelect: PropTypes.func.isRequired, r: PropTypes.number.isRequired, strokeOpacity: PropTypes.number.isRequired, defaultClassName: PropTypes.string, selected: PropTypes.bool, stroke: PropTypes.string.isRequired, strokeWidth: PropTypes.number.isRequired, strokeDasharray: PropTypes.oneOf(strokeDashTypes), edgeStrokeWidth: PropTypes.number.isRequired, edgeStroke: PropTypes.string.isRequired, edgeInteractiveCursor: PropTypes.string.isRequired, lineInteractiveCursor: PropTypes.string.isRequired, edgeFill: PropTypes.string.isRequired, hoverText: PropTypes.object.isRequired, }; EachRectangle.defaultProps = { onDrag: noop, onEdge1Drag: noop, onEdge2Drag: noop, onDragComplete: noop, onSelect: noop, onUnSelect: noop, selected: false, edgeStroke: "#000000", edgeFill: "#FFFFFF", edgeStrokeWidth: 2, r: 5, strokeWidth: 1, strokeOpacity: 1, strokeDasharray: "Solid", hoverText: { enable: false, } }; export default EachRectangle;
3. Создадим файл Rectangle.js в папке interactive. Это компонент rectangle верхнего уровня, который используется для рисования прямоугольника.
import React, { Component } from "react"; import PropTypes from "prop-types"; import { isDefined, isNotDefined, noop, strokeDashTypes } from "../utils"; import { getValueFromOverride, terminate, saveNodeType, isHoverForInteractiveType, } from "./utils"; import EachRectangle from "./wrapper/EachRectangle"; import MouseLocationIndicator from "./components/MouseLocationIndicator"; import HoverTextNearMouse from "./components/HoverTextNearMouse"; class Rectangle extends Component { constructor(props) { super(props); this.handleStart = this.handleStart.bind(this); this.handleEnd = this.handleEnd.bind(this); this.handleDrawLine = this.handleDrawLine.bind(this); this.handleDragLine = this.handleDragLine.bind(this); this.handleDragLineComplete = this.handleDragLineComplete.bind(this); this.terminate = terminate.bind(this); this.saveNodeType = saveNodeType.bind(this); this.getSelectionState = isHoverForInteractiveType("trends") .bind(this); this.state = { }; this.nodes = []; } handleDragLine(index, newXYValue) { this.setState({ override: { index, ...newXYValue } }); } handleDragLineComplete(moreProps) { const { override } = this.state; if (isDefined(override)) { const { trends } = this.props; const newTrends = trends .map((each, idx) => idx === override.index ? { ...each, start: [override.x1Value, override.y1Value], end: [override.x2Value, override.y2Value], selected: true, } : { ...each, selected: false, }); this.setState({ override: null, }, () => { this.props.onComplete(newTrends, moreProps); }); } } handleDrawLine(xyValue) { const { current } = this.state; if (isDefined(current) && isDefined(current.start)) { this.mouseMoved = true; this.setState({ current: { start: current.start, end: xyValue, } }); } } handleStart(xyValue, moreProps, e) { const { current } = this.state; if (isNotDefined(current) || isNotDefined(current.start)) { this.mouseMoved = false; this.setState({ current: { start: xyValue, end: null, }, }, () => { this.props.onStart(moreProps, e); }); } } handleEnd(xyValue, moreProps, e) { const { current } = this.state; const { trends, appearance, type } = this.props; if (this.mouseMoved && isDefined(current) && isDefined(current.start) ) { const newTrends = [ ...trends.map(d => ({ ...d, selected: false })), { start: current.start, end: xyValue, selected: true, appearance, type, } ]; this.setState({ current: null, trends: newTrends }, () => { this.props.onComplete(newTrends, moreProps, e); }); } } render() { const { appearance } = this.props; const { enabled, snap, shouldDisableSnap, snapTo, type } = this.props; const { currentPositionRadius, currentPositionStroke } = this.props; const { currentPositionstrokeOpacity, currentPositionStrokeWidth } = this.props; const { hoverText, trends } = this.props; const { current, override } = this.state; const tempLine = isDefined(current) && isDefined(current.end) ? : null; return {trends.map((each, idx) => { const eachAppearance = isDefined(each.appearance) ? { ...appearance, ...each.appearance } : appearance; const hoverTextWithDefault = { ...Rectangle.defaultProps.hoverText, ...hoverText }; return ; })} {tempLine} ; } } Rectangle.propTypes = { snap: PropTypes.bool.isRequired, enabled: PropTypes.bool.isRequired, snapTo: PropTypes.func, shouldDisableSnap: PropTypes.func.isRequired, onStart: PropTypes.func.isRequired, onComplete: PropTypes.func.isRequired, onSelect: PropTypes.func, currentPositionStroke: PropTypes.string, currentPositionStrokeWidth: PropTypes.number, currentPositionstrokeOpacity: PropTypes.number, currentPositionRadius: PropTypes.number, type: PropTypes.oneOf(['RECTANGLE']), hoverText: PropTypes.object.isRequired, trends: PropTypes.array.isRequired, appearance: PropTypes.shape({ isFill: true, stroke: PropTypes.string.isRequired, strokeOpacity: PropTypes.number.isRequired, strokeWidth: PropTypes.number.isRequired, strokeDasharray: PropTypes.oneOf(strokeDashTypes), edgeStrokeWidth: PropTypes.number.isRequired, edgeFill: PropTypes.string.isRequired, edgeStroke: PropTypes.string.isRequired, }).isRequired }; Rectangle.defaultProps = { type: "RECTANGLE", onStart: noop, onComplete: noop, onSelect: noop, currentPositionStroke: "#000000", currentPositionstrokeOpacity: 1, currentPositionStrokeWidth: 3, currentPositionRadius: 0, shouldDisableSnap: e => (e.button === 2 || e.shiftKey), hoverText: { ...HoverTextNearMouse.defaultProps, enable: true, bgHeight: "auto", bgWidth: "auto", text: "Click to select object", selectedText: "", }, trends: [], appearance: { stroke: "#000000", strokeOpacity: 1, strokeWidth: 1, strokeDasharray: "Solid", edgeStrokeWidth: 1, edgeFill: "#FFFFFF", edgeStroke: "#000000", r: 6, fill: '#8AAFE2', fillOpacity: 0.7, text: '', } }; export default Rectangle;
Результат рисования прямоугольника представлен на рисунке:

В результате можно расширить библиотеку, добавляя различные элементы рисования и
индикаторы. Дополнительно можно создать диалоговые окна с настройками элементов
графика. Разработчики, которым интересен проект, могут поучаствовать в дальнейшем его
развитии.
Основные проблемы, с которыми пришлось столкнуться при разработке
В библиотеке используется ряд устаревших методов жизненного цикла компонента, например,
componentWillReceiveProps. Как известно, этот метод не будет поддерживаться в следующих
версиях React (начиная с 17). Переписывание логики, заложенной в данном методе, потребует
значительных трудозатрат и дополнительного тестирования.
Заключение
Библиотека React Stockcharts позволила быстро решить задачу, показала высокую
производительность и совместимость с различными версиями браузеров. Не было замечено
каких-либо значительных визуальных задержек при рисовании различных элементов на
графике, при получении данных в онлайн режиме. Благодаря широкому функционалу,
библиотека может быть использована как готовое решение для создания торговых терминалов,
а также сайтов, отображающих информацию с финансовых рынков.
ссылка на оригинал статьи https://habr.com/ru/company/auriga/blog/505638/
Добавить комментарий