| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061 |
- 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<HTMLCanvasElement>} */
- 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<string>} */
- xScale = null;
- /** @type {d3Scale.ScaleLinear<number, number>} */
- bidAskScale = null;
- /** @type {d3Scale.ScaleBand<string>} */
- 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 (
- <canvas ref={this.canvasRef} width={width || 300} height={height || 150} tabIndex={1}
- style={{
- display: 'block',
- width: '100%',
- height: '100%',
- cursor: 'crosshair',
- paddingRight: '3%',
- boxSizing: "border-box"
- }}></canvas>
- );
- }
- }
|