import React from 'react'; import * as d3Scale from 'd3-scale'; import * as d3Array from 'd3-array'; import * as d3Color from 'd3-color'; import * as d3Format from 'd3-format'; import * as d3Interpolate from 'd3-interpolate'; import * as d3Shape from 'd3-shape'; import * as d3Timer from 'd3-timer'; import * as d3Ease from 'd3-ease'; import { extractBidPrices, extractBidVolumes, extractMaxTradedVolume, extractAvgTradedVolume, extractMaxVolume, zoomTimeFormat, extractMaxAskBidVolume } from "./utils"; export const d3 = Object.assign( Object.assign( Object.assign({}, d3Scale, d3Array, d3Color) , d3Format, d3Interpolate, d3Shape ) , d3Ease, d3Timer ); /** * Stock Heatmap * */ export default class StockHeatmap extends React.Component { /** @type {React.RefObject} */ canvasRef = React.createRef(); /** @type {CanvasRenderingContext2D} */ drawingContext = null; data = []; windowedData = []; windowLength = 20; windowPosition = 0; isMerged = false; mouse = { x: 0, y: 0 } circles = []; maxBidAskVolume = 0; orderbookColors = [ '#086892', '#2f9dd2', '#fffc19', '#ff9a01', ]; /** Default Theme colors and dimensions */ defaults = { borderPadding: [5, 5, 0, 0], bidAskWidth: 100, axisYWidth: 50, axisXHeight: 50, buyColor: '#388e3c', textOnBuyColor: '#ffffff', sellColor: '#d32f2f', textOnSellColor: '#ffffff', textOnBackground: '#000000', textHighlightOnBackground: '#ff0000', tradeColor: '#7434eb', axisTickSize: 6, axisColor: '#000000', xAxisTextPadding: 6, yAxisTextPadding: 6, bidAskGraphPaddingLeft: 10, bidAskTransitionDuration: 500, volumeCircleMaxRadius: 20, runningRatioSeconds: 5, hmWidth: () => (this.props.width - this.defaults.borderPadding[1] - this.defaults.borderPadding[3] - this.defaults.bidAskWidth - this.defaults.axisYWidth), hmHeight: () => (this.props.height - this.defaults.borderPadding[0] - this.defaults.borderPadding[2] - this.defaults.axisXHeight), clearColor: '#ffffff', }; shouldComponentUpdate(nextProps, nextState) { // console.log('shouldComponentUpdate', nextProps); const shouldUpdate = this.props.width !== nextProps.width || this.props.height !== nextProps.height; if (shouldUpdate) { this.detachMouseListeners(); } return shouldUpdate; } // -------------------START:: Lifecycle methods to retrive 2d context from updated dom------------------------- componentDidMount() { // console.log('component mouted'); if (this.canvasRef.current !== null) { this.drawingContext = this.canvasRef.current.getContext('2d'); this.updateHeatmap(); this.attachMouseListeners(); } const panel = this setInterval(() => { panel.updateHeatmap() }, 10) } componentDidUpdate() { // console.log('component updtated'); if (this.canvasRef.current !== null) { this.drawingContext = this.canvasRef.current.getContext('2d'); this.updateHeatmap(); this.attachMouseListeners(); } } componentWillUnmount() { this.detachMouseListeners(); } // -------------------END:: Lifecycle methods to retrive 2d context from updated dom--------------------------- // ------------------ START:: Mouse Event listeners ------------------- isMouseDown = false; mouseDownX = 0; /** * Attaches mouse interaction event listeners */ attachMouseListeners = () => { if (this.canvasRef.current !== null) { this.canvasRef.current.addEventListener('mousedown', this.eventMouseDown); this.canvasRef.current.addEventListener('mousemove', this.eventMouseMove); this.canvasRef.current.addEventListener('mouseup', this.eventMouseUp); this.canvasRef.current.addEventListener('wheel', this.eventZoomWheel); this.canvasRef.current.addEventListener('keydown', this.eventKeyDown); } } /** * Detaches mouse interaction event listeners */ detachMouseListeners = () => { if (this.canvasRef.current !== null) { this.canvasRef.current.removeEventListener('mousedown', this.eventMouseDown); this.canvasRef.current.removeEventListener('mousemove', this.eventMouseMove); this.canvasRef.current.removeEventListener('mouseup', this.eventMouseUp); this.canvasRef.current.removeEventListener('wheel', this.eventZoomWheel); this.canvasRef.current.removeEventListener('keydown', this.eventKeyDown); } } /** * Mouse down event on canvas * @param {MouseEvent} e */ eventMouseDown = (e) => { // console.log('eventMouseDown', e); if (!this.isMouseDown) { this.isMouseDown = true; this.mouseDownX = e.clientX; } } /** * Mouse move event on canvas * @param {MouseEvent} e */ eventMouseMove = (e) => { // 判断鼠标拖拽距离,这个只是判断拖拽是否满足阈值,使其画面滚动暂停 const downDragLength = Math.abs(e.clientX - this.mouseDownX); if (this.isMouseDown && downDragLength > 200) { this.props.toggleAutoScroll(false) } // 其他事件处理 if (this.isMouseDown && this.xScale) { // Mouse drag, scroll the time series,距离上一次移动视野的鼠标拖拽距离 let dragX = e.clientX - this.mouse.x; // 合并数据,拖动范围要增加 if (this.isMerged) { dragX = dragX * 10; } const moveDataPointsCount = Math.floor(Math.abs(dragX) / this.xScale.bandwidth()); if (moveDataPointsCount > 0) this.mouse.x = e.x // const moveDataPointDirection = dragLength >= 0 ? 'right' : 'left'; // console.log('drag x=', dragLength, moveDataPointsCount, this.windowPosition); this.moveDataWindow(this.windowPosition + moveDataPointsCount * (dragX >= 0 ? -1 : 1)); } else { if (e.clientX < this.defaults.hmWidth() && e.clientY < this.defaults.hmHeight()) { this.mouse = { x: e.clientX, y: e.clientY }; } } } /** * Mouse up event on canvas * @param {MouseEvent} e */ eventMouseUp = (e) => { // console.log('eventMouseUp',e); this.isMouseDown = false; this.mouseDownX = 0; } /** * Wheel event on canvas to zoom * @param {WheelEvent} e */ eventZoomWheel = (e) => { const direction = e.deltaY < 0 ? 'zoom-in' : 'zoom-out'; let l = 0, l2 = 0; switch (direction) { case 'zoom-in': l = Math.max(this.windowLength - 16, 3); break; case 'zoom-out': l = Math.min(this.windowLength + 16, this.data.length - 16); break; } l2 = this.windowLength - l; this.windowLength = l; this.moveDataWindow(this.windowPosition + l2); // console.log('zoom Level=', this.windowLength); } /** * Event to be triggered when keyboard key is pressed * @param {KeyboardEvent} e */ eventKeyDown = (e) => { e.preventDefault(); console.log('key event', e.isComposing, e.key, e.ctrlKey); switch (e.key) { case 'ArrowLeft': this.moveDataWindow(this.windowPosition - (e.ctrlKey ? 10 : 1)); break; case 'ArrowRight': this.moveDataWindow(this.windowPosition + (e.ctrlKey ? 10 : 1)); break; } } // ------------------ END:: Mouse Event listeners --------------------- // ------------------ D3 Variables --------------------- /** @type {d3Scale.ScaleBand} */ xScale = null; /** @type {d3Scale.ScaleLinear} */ bidAskScale = null; /** @type {d3Scale.ScaleBand} */ yScale = null; /** @type {number[]} */ yDomainValues = null; /** @type {d3Timer.Timer} */ bidAskAnimTimer = null; // ------------------ D3 Variables --------------------- /** * This function will be called if there is any dimension change on heatmap * This function changes the d3 scales based on windowed data */ updateHeatmapDimensions = () => { const { width, height } = this.props; if (width > 0 && height > 0 && this.windowedData.length > 0) { // setup x-scale this.xScale = d3.scaleBand() .range([0, this.defaults.hmWidth()]) .domain(this.windowedData.map(d => d.ts)); // setup y-scale this.yDomainValues = extractBidPrices(this.windowedData).sort((a, b) => a - b); this.yScale = d3.scaleBand() .range([this.defaults.hmHeight(), 0]) .domain(this.yDomainValues); // setup bid ask scale this.bidAskScale = d3.scaleLinear() .range([0, this.defaults.bidAskWidth]) .domain([0, d3.max(extractBidVolumes(this.windowedData[this.windowedData.length - 1]))]); } } /** * This method will be called after an update of internal data is performed. */ updateHeatmap = () => { if (this.drawingContext !== null) { // 1. update scale and dimensions this.updateHeatmapDimensions(); // 2. Draw the bid ask spread heatmap this.clearCanvas(this.defaults.borderPadding[3], this.defaults.borderPadding[0], this.defaults.hmWidth(), this.defaults.hmHeight(), this.defaults.clearColor); this.drawMainGraph(); // 3. Draw xy Axis this.drawXAxis(); this.drawYAxisAndBidAskGraph(); // 4. Draw buy-to-sell ratio this.drawBuy2SellRatio(); // 5. Draw mouse this.drawMouse(); // console.log('heatmap draw update'); // this.clearCanvas(0, 0, this.defaults.hmWidth(), this.defaults.hmHeight(), '#aaaaaa'); } } // ------------------------------ START: Canvas draw functions --------------------------------------- getMousePos = (canvas, x, y) => { var rect = canvas.getBoundingClientRect(); // 使用容器的内部宽度进行计算,排除 padding 影响 var style = window.getComputedStyle(canvas.parentNode); var paddingLeft = parseFloat(style.paddingLeft) || 0; var paddingRight = parseFloat(style.paddingRight) || 0; var effectiveWidth = rect.width - paddingLeft - paddingRight; return { x: (x - rect.left - paddingLeft) * (canvas.width / effectiveWidth) * 1.03, y: (y - rect.top) * (canvas.height / rect.height) }; } drawMouse = () => { const canvas = this.canvasRef.current; if (!canvas) return; const context = canvas.getContext('2d'); if (!context) return; let mouse = this.getMousePos(canvas, this.mouse.x, this.mouse.y) const x = mouse.x; const y = mouse.y; // 移动事件处理,指上圆球事件处理 let drawPending = [] this.circles.forEach(circle => { const distance = Math.sqrt(Math.pow(x - circle.x, 2) + Math.pow(y - circle.y, 2)); if (distance <= circle.radius) { let d = circle.data drawPending.push(d) } }); if (drawPending.length > 0) { const WIDTH = 100 const HEIGHT = drawPending.length * 45 // 绘制方块 let color = d3.color('#fff').rgb(); color.opacity = 0.7 this.drawingContext.fillStyle = color.toString(); this.drawingContext.fillRect( x + 2, y + 2, WIDTH, HEIGHT ); // 绘制方块内数据 drawPending.map((d, index) => { let depth = d.marketDepth; let text = depth.lastSellQty !== 0 ? `卖出 ${depth.lastSellQty} 在 ${depth.lastSellPrice}` : `买入 ${depth.lastBuyQty} 在 ${depth.lastBuyPrice}` context.fillStyle = d3.color(depth.side === 'sell' ? '#222' : '#222').rgb(); context.fillText(`${d.ts}`, x + 10, y + 7 + 15 + index * 45); context.fillText(text, x + 10, y + 7 + 30 + index * 45); if (index < drawPending.length - 1) { context.fillText("--------------", x + 10, y + 7 + 45 + index * 45); } }) } // // // 清除canvas并重新绘制 // context.clearRect(0, 0, canvas.width, canvas.height); // 绘制交叉线和文本 context.beginPath(); context.moveTo(x, 30); context.lineTo(x, this.defaults.hmHeight()); context.moveTo(30, y); context.lineTo(this.defaults.hmWidth(), y); context.strokeStyle = '#000'; context.stroke(); } /** * Draw buy/sell ratio at bottom right corner */ drawBuy2SellRatio = () => { if (this.windowedData.length > 0) { // dimension const d = this.windowedData[this.windowedData.length - 1]; const x = this.defaults.borderPadding[3] + this.defaults.hmWidth() + this.defaults.axisTickSize; const y = this.defaults.borderPadding[0] + this.defaults.hmHeight() + this.defaults.axisTickSize; const w = this.props.width - x; const h = this.props.height - y; this.clearCanvas(x, y, w, h, this.defaults.clearColor); let textHeight = (h - 10) / 2; this.drawingContext.save(); this.drawingContext.textAlign = 'center'; this.drawingContext.textBaseline = 'middle'; // this.drawingContext.font = `bold ${textHeight}px sans-serif`; // this.drawingContext.fillText(, x + w * 1/2, y + textHeight / 2); // // Runing average ratio // if(this.windowedData.length >= this.defaults.runningRatioSeconds) { // let sellT20RunningSum = 0; // let buyT20RunningSum = 0; // for (let i = this.windowedData.length - 1; i >= this.windowedData.length - this.defaults.runningRatioSeconds; i--) { // sellT20RunningSum += (this.windowedData[i].marketDepth.sells || []).reduce((vol, s) => vol + s.qty,0); // buyT20RunningSum += (this.windowedData[i].marketDepth.buys || []).reduce((vol, s) => vol + s.qty,0); // } // const newBSTPFactor = (buyT20RunningSum / sellT20RunningSum); // this.drawingContext.fillText(newBSTPFactor.toFixed(2), x + w /4, y + textHeight *0.5); // } this.drawingContext.font = `bold ${13}px sans-serif`; this.drawingContext.textBaseline = 'bottom'; this.drawingContext.fillText(`买卖比: ${(d.marketDepth.buyOrderVolume / d.marketDepth.sellOrderVolume).toFixed(2)}`, x + w / 2, y + textHeight * 2 + 5); this.drawingContext.restore(); } } /** * Draws X Axis */ drawXAxis = () => { // clear canvas before axis draw this.clearCanvas( this.defaults.borderPadding[3], this.defaults.borderPadding[0] + this.defaults.hmHeight(), this.defaults.hmWidth(), this.defaults.axisXHeight, this.defaults.clearColor ); // draw axis this.drawingContext.save(); this.drawingContext.beginPath(); this.drawingContext.translate(this.defaults.borderPadding[3], this.defaults.borderPadding[0] + this.defaults.hmHeight()); this.drawingContext.moveTo(0, 0); this.drawingContext.lineTo(this.defaults.hmWidth(), 0); this.drawingContext.textAlign = 'center'; this.drawingContext.textBaseline = 'top'; // const assumedTextWidth = this.drawingContext.measureText('77:77:77').width + 20; // const maxTickInterval = this.defaults.hmWidth() / assumedTextWidth; const bandInterval = Math.max(1, parseInt(this.windowedData.length / (this.defaults.hmWidth() / 102))); // console.log('bandInterval=', bandInterval); let panel = this; this.windowedData.map((d, i) => { if (i !=0 && i % bandInterval === 0) { let x = this.xScale(d.ts); if (x + d.ts.length * 5 < panel.defaults.hmWidth()) { this.drawingContext.moveTo(x, 0); this.drawingContext.lineTo(x, this.defaults.axisTickSize); this.drawingContext.fillText(d.ts, x, this.defaults.axisTickSize + this.defaults.xAxisTextPadding); } } }); this.drawingContext.textAlign = 'left'; this.drawingContext.font = '12px Arial'; let zoomLevelText = `当前视域: ${zoomTimeFormat(this.windowLength)}` this.drawingContext.fillText(zoomLevelText, 20, this.defaults.axisTickSize + this.defaults.xAxisTextPadding + 20); let w = this.drawingContext.measureText(zoomLevelText).width; const maxVolumeInWindowData = extractMaxTradedVolume(this.windowedData); const maxVolumeText = `最近${zoomTimeFormat(this.windowLength, 1)}内最大交易量: `; this.drawingContext.fillText(maxVolumeText, 20 + w + 20, this.defaults.axisTickSize + this.defaults.xAxisTextPadding + 20); this.drawingContext.fillStyle = this.defaults.textHighlightOnBackground; w += this.drawingContext.measureText(maxVolumeText).width; this.drawingContext.font = 'bold 12px Arial'; this.drawingContext.fillText(`${maxVolumeInWindowData}`, 20 + w + 20, this.defaults.axisTickSize + this.defaults.xAxisTextPadding + 20); w += this.drawingContext.measureText(`${maxVolumeInWindowData}`).width; let latested = this.windowedData[this.windowedData.length - 1] if (this.windowedData.length > 0) { this.drawingContext.fillStyle = this.defaults.textOnBackground; this.drawingContext.font = '12px Arial'; this.drawingContext.fillText(` 最后交易价格: ${latested.marketDepth.side === 'buy' ? latested.marketDepth.lastBuyPrice : latested.marketDepth.lastSellPrice} 最后交易数量: ${latested.marketDepth.side === 'buy' ? latested.marketDepth.lastBuyQty : latested.marketDepth.lastSellQty} 最后交易时间: ${latested.ts} `, 20 + w + 40, this.defaults.axisTickSize + this.defaults.xAxisTextPadding + 20); } this.drawingContext.fillStyle = this.defaults.textOnBackground; this.drawingContext.lineWidth = 1.2; this.drawingContext.strokeStyle = this.defaults.axisColor; this.drawingContext.stroke(); this.drawingContext.restore(); } /** * Draws Y Axis and Bid Ask graph at the same time */ drawYAxisAndBidAskGraph = () => { if (this.yDomainValues !== null) { const yh2 = this.yScale.bandwidth() * 0.5; // clear canvas before axis draw this.clearCanvas( this.defaults.borderPadding[3] + this.defaults.hmWidth(), this.defaults.borderPadding[0], this.defaults.axisYWidth, this.defaults.hmHeight() + this.defaults.axisTickSize, this.defaults.clearColor ); // translate and draw this.drawingContext.save(); this.drawingContext.beginPath(); this.drawingContext.translate(this.defaults.borderPadding[3] + this.defaults.hmWidth(), this.defaults.borderPadding[0]); this.drawingContext.moveTo(0, 0); this.drawingContext.lineTo(0, this.defaults.hmHeight() + this.defaults.axisTickSize); this.drawingContext.textAlign = 'start'; this.drawingContext.textBaseline = 'top'; let maxTextWidth = 0; this.yDomainValues.map(d => { let y = this.yScale(d) + yh2; this.drawingContext.moveTo(0, y); this.drawingContext.lineTo(this.defaults.axisTickSize, y); // 大于7位,换行绘制 if (String(d).length <= 7) { y -= 5 this.drawingContext.fillText(d, this.defaults.axisTickSize + this.defaults.yAxisTextPadding, y, this.defaults.axisYWidth - this.defaults.axisTickSize + this.defaults.yAxisTextPadding); } else { y -= 10 let text = String(d) let [t0, t1] = text.split('.') this.drawingContext.fillText(t0, this.defaults.axisTickSize + this.defaults.yAxisTextPadding, y, this.defaults.axisYWidth - this.defaults.axisTickSize + this.defaults.yAxisTextPadding); this.drawingContext.fillText('.'.concat(t1), this.defaults.axisTickSize + this.defaults.yAxisTextPadding, y + 10, this.defaults.axisYWidth - this.defaults.axisTickSize + this.defaults.yAxisTextPadding); } let tw = this.drawingContext.measureText(d).width; maxTextWidth = maxTextWidth >= tw ? maxTextWidth : tw; }); this.drawingContext.lineWidth = 1.2; this.drawingContext.strokeStyle = this.defaults.axisColor; this.drawingContext.stroke(); this.drawingContext.restore(); // Now I will draw the bid ask strength graph, const x = this.defaults.borderPadding[3] + this.defaults.hmWidth() + maxTextWidth + this.defaults.axisTickSize + this.defaults.yAxisTextPadding + this.defaults.bidAskGraphPaddingLeft; const y = this.defaults.borderPadding[0]; this.drawBidAskGraph(x, y); } } /** * Draw and animate Bid Ask graph * @param {number} x * @param {number} y */ drawBidAskGraph = (x, y) => { if (this.windowedData.length > 0) { if (this.bidAskAnimTimer !== null) { this.bidAskAnimTimer.stop(); this.bidAskAnimTimer = null; } this.bidAskAnimTimer = d3.timer(elapsed => { // compute how far through the animation we are (0 to 1) const t = Math.min(1, d3.easeCubic(elapsed / this.defaults.bidAskTransitionDuration)); // ----------------draw-------------------- // console.log('drawing bid ask graph'); this.clearCanvas( x, y, this.defaults.bidAskWidth, this.defaults.hmHeight() + this.defaults.axisTickSize, this.defaults.clearColor ); const h = this.yScale.bandwidth() - 2; const d = this.windowedData[this.windowedData.length - 1]; if (!d) { return } const maxBidAskVol = extractMaxVolume(d); this.drawingContext.save(); this.drawingContext.translate(x, y); this.drawingContext.lineWidth = 0; this.drawingContext.textBaseline = 'middle'; const drawBars = (arr, color, textColor) => { arr.map(v => { this.drawingContext.fillStyle = color; const l = this.defaults.bidAskWidth * (+v.qty / maxBidAskVol); // save v bars length this.drawingContext.fillRect(0, this.yScale(v.rate), l, h); let textWidth = this.drawingContext.measureText(v.qty).width; if (this.defaults.bidAskWidth - l - textWidth >= textWidth) { // text outside bar this.drawingContext.textAlign = 'start'; this.drawingContext.fillStyle = this.defaults.textOnBackground; this.drawingContext.fillText(v.qty, l + 1, this.yScale(v.rate) + h / 2 + 1); } else { this.drawingContext.textAlign = 'end'; this.drawingContext.fillStyle = textColor; this.drawingContext.fillText(v.qty, l - textWidth, this.yScale(v.rate) + h / 2 + 1); } }); } drawBars(d.marketDepth.buys, this.defaults.buyColor, this.defaults.textOnBuyColor); drawBars(d.marketDepth.sells, this.defaults.sellColor, this.defaults.textOnSellColor); this.drawingContext.restore(); // ----------------draw-------------------- // if this animation is over if (t === 1) this.bidAskAnimTimer.stop(); }); } } /** * Draws background heatmap for both buys and sells */ drawMainGraph = () => { this.drawingContext.save(); if (this.xScale && this.yScale && this.bidAskScale && this.drawingContext !== null) { const avgTradedVolume = extractAvgTradedVolume(this.windowedData); const maxBidAskVolume = extractMaxAskBidVolume(this.windowedData); const xh2 = this.xScale.bandwidth() * 0.5; const yh2 = this.yScale.bandwidth() * 0.5; const panel = this this.drawingContext.translate(this.defaults.borderPadding[3], this.defaults.borderPadding[0]); this.windowedData.map(d => { const marketDepth = d.marketDepth; const ask1 = marketDepth.sells[0]; const bid1 = marketDepth.buys[0]; const ts = d.ts; // draw buys if (marketDepth.buys && marketDepth.buys.length > 0) { marketDepth.buys.map(buy => { let rate = buy.qty / maxBidAskVolume; let colorIndex = Math.min(parseInt(rate / 0.25), panel.orderbookColors.length - 1); let color = d3.color(panel.orderbookColors[colorIndex]).rgb() color.opacity = 0.5 + 0.5 * (rate % 0.25) / 0.25; this.drawingContext.fillStyle = color.toString(); this.drawingContext.fillRect( this.xScale(ts), this.yScale(buy.rate), // 减去半个方块的高度 this.xScale.bandwidth(), this.yScale.bandwidth() ); }); } // draw sells if (marketDepth.sells && marketDepth.sells.length > 0) { marketDepth.sells.map(sell => { let rate = sell.qty / maxBidAskVolume; let colorIndex = Math.min(parseInt(rate / 0.25), panel.orderbookColors.length - 1); let color = d3.color(panel.orderbookColors[colorIndex]).rgb() color.opacity = 0.5 + 0.5 * (rate % 0.25) / 0.25; this.drawingContext.fillStyle = color.toString(); this.drawingContext.fillRect( this.xScale(ts), this.yScale(sell.rate), // 减去半个方块的高度 this.xScale.bandwidth(), this.yScale.bandwidth() ); }); } }); // draw trade size this.circles = [] this.windowedData.map(d => { const marketDepth = d.marketDepth; const maxBidAskVol = extractMaxVolume(d); const ts = d.ts; const ask1 = marketDepth.sells[0]; const bid1 = marketDepth.buys[0]; // 绘制买入的圆圈 if (marketDepth.lastBuyQty !== 0) { let trade_color = d3.color("#44c98b").rgb(); trade_color.opacity = 0.7; this.drawingContext.fillStyle = trade_color.toString(); const r = 50 - 45 * (2.71 ** (-0.01 * (+marketDepth.lastBuyQty / avgTradedVolume))); this.drawingContext.beginPath(); this.drawingContext.arc( this.xScale(ts) + xh2, this.yScale(+marketDepth.lastBuyPrice) + yh2, r, 0, 2 * Math.PI ); this.drawingContext.strokeStyle = trade_color; this.drawingContext.fill(); // 为球添加白色边框 this.drawingContext.lineWidth = 1; // 设置线宽,可以根据需要调整 this.drawingContext.strokeStyle = "white"; // 设置描边颜色为白色 this.drawingContext.stroke(); // 执行描边操作 // 事件触发用 let d_copy = JSON.parse(JSON.stringify(d)) // 只保存需要展示的那边 d_copy.marketDepth.lastSellQty = 0 d_copy.marketDepth.lastSellPrice = 0 this.circles.push({ x: this.xScale(ts) + xh2, y: this.yScale(+marketDepth.lastBuyPrice) + yh2, radius: r, data: d_copy // 存储与圆相关的数据,便于在事件处理时使用 }); } // 绘制卖出的圆圈 if (marketDepth.lastSellQty !== 0) { let trade_color = d3.color("#cc5040").rgb(); trade_color.opacity = 0.7; this.drawingContext.fillStyle = trade_color.toString(); const r = 50 - 45 * (2.71 ** (-0.01 * (+marketDepth.lastSellQty / avgTradedVolume))); this.drawingContext.beginPath(); this.drawingContext.arc( this.xScale(ts) + xh2, this.yScale(+marketDepth.lastSellPrice) + yh2, r, 0, 2 * Math.PI ); this.drawingContext.strokeStyle = trade_color; this.drawingContext.fill(); // 为球添加白色边框 this.drawingContext.lineWidth = 1; // 设置线宽,可以根据需要调整 this.drawingContext.strokeStyle = "white"; // 设置描边颜色为白色 this.drawingContext.stroke(); // 执行描边操作 // 事件触发用 let d_copy = JSON.parse(JSON.stringify(d)) // 只保存需要展示的那边 d_copy.marketDepth.lastBuyQty = 0 d_copy.marketDepth.lastBuyPrice = 0 this.circles.push({ x: this.xScale(ts) + xh2, y: this.yScale(+marketDepth.lastSellPrice) + yh2, radius: r, data: d_copy // 存储与圆相关的数据,便于在事件处理时使用 }); } }) // draw buy line path let buy_line_color = d3.color(this.defaults.buyColor).rgb(); this.drawingContext.fillStyle = buy_line_color.toString(); this.drawingContext.beginPath(); d3.line() .x(d => this.xScale(d.ts)) .y(d => this.yScale(d.marketDepth.buys[0].rate) + yh2) // .curve(d3.curveLinear) .context(this.drawingContext) (this.windowedData); this.drawingContext.lineWidth = 2; this.drawingContext.strokeStyle = this.defaults.buyColor; this.drawingContext.stroke(); // draw sell line path let sell_line_color = d3.color(this.defaults.sellColor).rgb(); this.drawingContext.fillStyle = sell_line_color.toString(); this.drawingContext.beginPath(); d3.line() .x(d => this.xScale(d.ts)) .y(d => this.yScale(d.marketDepth.sells[0].rate) + yh2) // .curve(d3.curveLinear) .context(this.drawingContext) (this.windowedData); this.drawingContext.lineWidth = 2; this.drawingContext.strokeStyle = this.defaults.sellColor; this.drawingContext.stroke(); } this.drawingContext.restore(); } /** * Clear the canvas area * @param {number} x x coordinate * @param {number} y y xoordinate * @param {number} w width * @param {number} h height * @param {string} color color string */ clearCanvas = (x, y, w, h, color) => { // console.log('clear canvas area', x, y, w, h, color); if (this.drawingContext !== null) { this.drawingContext.save(); this.drawingContext.fillStyle = color || this.defaults.clearColor; this.drawingContext.fillRect(x, y, w, h); this.drawingContext.restore(); } } // ------------------------------ END: Canvas draw functions --------------------------------------- /** * Set Data for the Heatmap to generate * @param {any[]} data The data to set */ setData = (data) => { // console.log('setdata called=', data); if (data && data.length > 0) { this.data = data; this.updateWindowedData(); } } /** * Add as extra data to existing data array. * @param {any} data */ addData = (data) => { if (typeof (data) === 'object') { this.data.push(data); this.updateWindowedData(); } } /** * This updates the data in array to be viewed in graph */ updateWindowedData = () => { // console.log('window data updated'); if (this.props.autoScroll) { this.moveDataWindow(this.data.length - this.windowLength - 1); } } isOrderBooksEquals = (orderbooks1, orderbooks2) => { for (let i = 0; i < orderbooks1.length; i++) { let orderbook1 = orderbooks1[i] let orderbook2 = orderbooks2[i] // 如果d1有,d2没有,那证明不相等 if (!orderbook2) return false if (orderbook1.rate !== orderbook2.rate) return false let r1 = (orderbook1.qty / this.maxBidAskVolume); let o1ColorIndex = Math.min(parseInt(r1 / 0.25), this.orderbookColors.length - 1); let r2 = (orderbook2.qty / this.maxBidAskVolume); let o2ColorIndex = Math.min(parseInt(r2 / 0.25), this.orderbookColors.length - 1); if (o1ColorIndex !== o2ColorIndex) return false } return true } isDepthEquals = (d1, d2) => { // buys if (!this.isOrderBooksEquals(d1.buys, d2.buys)) return false // sells if (!this.isOrderBooksEquals(d1.sells, d2.sells)) return false return true } mergeSnapshots = (snapshots) => { // 初始化合并后的结构 const merged = { marketDepth: { avgPrice: 0, buyOrderVolume: 0, sellOrderVolume: 0, lastBuyPrice: 0, lastBuyQty: 0, lastSellPrice: 0, lastSellQty: 0, lastTradedTS: 0, open: 0, high: 0, low: 0, close: 0, priceChangeAmt: 0, priceChangePct: "0", buys: [], sells: [] }, pendingOrders: [], time: "", tradingsymbol: "", ts: "" }; // 初始化计数器 let totalAvgPrice = 0; let totalBuyOrderVolume = 0; let totalSellOrderVolume = 0; let totalSnapshots = snapshots.length; // 初始化买卖数组 let buySums = Array(5).fill({ rate: 0, orders: 0, qty: 0 }); let sellSums = Array(5).fill({ rate: 0, orders: 0, qty: 0 }); // 初始化交易数量 let totalLastTradeQtyBuy = 0; let totalLastTradeQtySell = 0; // 记录第一个快照的时间和交易符号 merged.time = snapshots[0].time; merged.tradingsymbol = snapshots[0].tradingsymbol; merged.ts = snapshots[0].ts; // 遍历所有快照数据 snapshots.forEach(snapshot => { // 累加市场深度数据 totalAvgPrice += snapshot.marketDepth.avgPrice; totalBuyOrderVolume += snapshot.marketDepth.buyOrderVolume; totalSellOrderVolume += snapshot.marketDepth.sellOrderVolume; // 合并买单和卖单 snapshot.marketDepth.buys.forEach((buy, index) => { buySums[index] = { rate: buy.rate, orders: buySums[index].orders + buy.orders, qty: buySums[index].qty + buy.qty }; }); snapshot.marketDepth.sells.forEach((sell, index) => { sellSums[index] = { rate: sell.rate, orders: sellSums[index].orders + sell.orders, qty: sellSums[index].qty + sell.qty }; }); // 合并最后交易的数量和价格 totalLastTradeQtyBuy += snapshot.marketDepth.lastBuyQty; totalLastTradeQtySell += snapshot.marketDepth.lastSellQty; // 合并其他字段 merged.marketDepth.close = snapshot.marketDepth.close; merged.marketDepth.high = snapshot.marketDepth.high; merged.marketDepth.low = snapshot.marketDepth.low; merged.marketDepth.open = snapshot.marketDepth.open; merged.marketDepth.priceChangeAmt = snapshot.marketDepth.priceChangeAmt; merged.marketDepth.priceChangePct = snapshot.marketDepth.priceChangePct; merged.marketDepth.lastTradedTS = snapshot.marketDepth.lastTradedTS; }); // 计算平均市场深度数据 merged.marketDepth.avgPrice = totalAvgPrice / totalSnapshots; merged.marketDepth.buyOrderVolume = totalBuyOrderVolume / totalSnapshots; merged.marketDepth.sellOrderVolume = totalSellOrderVolume / totalSnapshots; // 计算平均买单和卖单 merged.marketDepth.buys = buySums.map(buy => ({ rate: buy.rate, orders: buy.orders / totalSnapshots, qty: buy.qty / totalSnapshots })); merged.marketDepth.sells = sellSums.map(sell => ({ rate: sell.rate, orders: sell.orders / totalSnapshots, qty: sell.qty / totalSnapshots })); // 计算最终的最后交易的数量和价格 merged.marketDepth.lastBuyPrice = merged.marketDepth.sells[0].rate; merged.marketDepth.lastBuyQty = totalLastTradeQtyBuy; merged.marketDepth.lastSellPrice = merged.marketDepth.buys[0].rate; merged.marketDepth.lastSellQty = totalLastTradeQtySell; merged.marketDepth.side = 'both'; return merged; } mergeWindowedData = () => { let windowedData = this.windowedData; this.maxBidAskVolume = extractMaxAskBidVolume(this.windowedData); let mergedWindowedData = []; let panel = this let prevData = undefined; let snapshots = []; windowedData.map((d, i) => { // 最后一个元素要展示,不然会丢失盘口细节 if (i === windowedData.length - 1) { mergedWindowedData.push(d) prevData = d // 如果是第一个数据,则进行初始化 } else if (!prevData) { prevData = d // 如果是中间的数据,则进行逻辑判断,是否需要合并 } else if (!panel.isDepthEquals(prevData.marketDepth, d.marketDepth)) { let mergedData = panel.mergeSnapshots(snapshots) mergedWindowedData.push(mergedData) prevData = d snapshots = []; } snapshots.push(d) }); return mergedWindowedData; } /** * Move the position of data window within the main data. * @param {number} position The target position of the window to be moved to. */ moveDataWindow = (position) => { if (position !== this.windowPosition && position > -1 && position < this.data.length - this.windowLength) { // move position only if within valid range this.windowedData = this.data.slice(position, position + this.windowLength + 1); if (this.windowedData.length > 200) { this.windowedData = this.mergeWindowedData(); this.isMerged = true; } else { this.isMerged = false; } // 延迟日志 if (this.windowedData.length > 1) { let last = this.windowedData[this.windowedData.length - 1] console.log(new Date().getTime() - last.time, last) } this.windowPosition = position; if (this.windowPosition === this.data.length - this.windowLength - 1) { // enable auto scroll this.props.toggleAutoScroll(true); } // console.log('moveDataWindow = ', position, this.windowPosition, this.windowLength, this.data.length, this.autoScroll, this.windowedData); // update the map this.updateHeatmap(); } } /** * This sets the Heatmap Zoom level aka. window. * @param {number} zoom The seconds to zoom into */ setZoomLevel = (zoom) => { let l = Math.min(Math.max(zoom * 4, 3), this.data.length - 1); let l2 = this.windowLength - l; this.windowLength = l; this.moveDataWindow(this.windowPosition + l2); } /** * Render Function */ render() { const { width, height } = this.props; // console.log('heatmap rendered', width, height, this.data); return ( ); } }