深度图d3绘制交互逻辑

12 阅读1分钟

深度图和价格图都是基于D3.js库来绘制的svg图形

D3.js (Data-Driven Documents) 是一个用于操作文档的JavaScript库,它可以通过使用HTML, SVG 和 CSS等技术在网页上动态生成数据可视化。下面跟着以下步骤实现深度图的绘制:

1.数据集定义

首先我们需要定义好我们要展示的数据,看下将要用到的数据集:liquidity表示y轴数据,token0Price表示x轴数据

[ {    "tick" : -887200,    "liquidity" : "2516871586768523698747878",    "token0Price" : "0.000000000000000000000000000000000000002960192591918355544122581114448831",    "token1Price" : "337815857904011940012765396015654000000",    "timestamp" : 1691676253239  }, {    "tick" : -610800,    "liquidity" : "2516871586768523698747878",    "token0Price" : "0.000000000000000000000000002982766743656641268864955452810751",    "token1Price" : "335259202593253190167580281.8803577",    "timestamp" : 1691676253239  },  ......  ]

2.设置画布

我们需要定义好将要使用的画布大小、内边距等属性。我们将会创建一个宽度为400像素,高度为200像素,且四周留有{ top: 20, right: 2, bottom: 20, left: 0 }像素的空白的画布

const margins = { top: 20, right: 2, bottom: 20, left: 0 };
const width = 400
const height = 200

3.定义比例尺

根据数据集中的数值范围,我们需要创建与之对应的比例尺。使用scaleLinear()函数分别创建x轴和y轴的比例尺:

使用domain()方法来设置比例尺的输入域(即数据范围),使用range()方法来确定输出范围(在画布上的位置)。

  // 计算好绘制面积
  const [innerHeight, innerWidth] = useMemo(() => {
    return [      height - margins.top - margins.bottom,      width - margins.left - margins.right,    ];
  }, [width, height, margins]);
 
 // 创建X轴、Y轴比例尺
 const scales = {
      xScale: scaleLinear()
        .domain([
          getPriceOnTick(computedCurrentPrice * zoomLevel.initialMin),
          getPriceOnTick(computedCurrentPrice * zoomLevel.initialMax),
        ])
        .range([0, innerWidth]),
      yScale: scaleLinear()
        .domain([
          0,
          max(formattedData, (d) => {
            return yAccessor(d); // Y轴上的最大值
          }),
        ])
        .range([innerHeight, 0]),
    };

4.创建面积图

现在我们需要根据流动性值,来绘制生成面积图。可以使用d3.area()来创建一个面积图:

该函数创建的面积图会使用series数据集合中的值,将x轴和y轴上的各个点用曲线连接起来;使用.curve()方法指定了折线的形状,curveStepAfter 是d3提供的一种曲线类型定义。

import { area, curveStepAfter } from 'd3';
export const xAccessor = (d) => {
  return d.price0;  //x轴取值
};
export const yAccessor = (d) => {
  return d.activeLiquidity; // y轴取值
};

/**
 * 
 * @param 
     xScale: x轴比例尺
     yScale: y轴比例尺
     series: 数据集合
     fill  :面积填充颜色
     xValue :xAccessor
     yValue :yAccessor
 * @returns 
 */
export const Area = ({ xScale, yScale, series, xValue, yValue, fill }) => {
  const chartArea =
    xScale && yScale
      ? area()
          .curve(curveStepAfter)
          .x((d) => {
            return xScale(xValue(d));
          })
          .y0(yScale(0))
          .y1((d) => {
            return yScale(yValue(d));
          })(
          series.filter((d) => {
            const value = xScale(xValue(d));
            return value > 0;
          })
        )
      : null;
  return useMemo(() => {
    return <path fill={fill} d={chartArea} />;
  }, [fill, series, xScale, xValue, yScale, yValue]);
};

5.设置X坐标轴

我们需要添加坐标轴和数字标签,以便更好地显示数据。

深度图,我们只需要设置X坐标轴,并通过调用g元素上的.call()方法向画布上添加了这些坐标轴。我们还使用.transform()方法将坐标轴移动到正确的位置,使用.ticks(number)设置显示刻度的数量,并使用.tickFormat()自定义格式化坐标轴数据,.attr()可以像css一样设置刻度的样式

//X坐标轴
export const AxisBottom = ({ xScale, innerHeight, offset = 0 }) => {
  return useMemo(() => {
    if (xScale) {
      return (
        <g transform={`translate(0, ${innerHeight + offset})`}>
          <Axis
            axisGenerator={axisBottom(xScale)
              .ticks(6)
              .tickFormat((d) => {
                return formatD3value(d);
              })}
          />
        </g>
      );
    }
    return null;
  }, [innerHeight, offset, xScale]);
};

const Axis = ({ axisGenerator }) => {
  const axisRef = (axis) => {
    axis &&
      select(axis)
        .call(axisGenerator)
        .call((g) => {
          return g.select('.domain').remove();
        })
        .call((g) => {
          // 移除刻度上的锯齿
          return g.selectAll('.tick line').attr('display', 'none');
        })
        .call((g) => {
          return g
            .selectAll('.tick text')
            .attr('transform', `translate(${0},${2})`)
            .attr('fill', '#BDBDBD')
            .attr('font-size', '8px');
        });
  };

  return <g ref={axisRef} />;
};

6.绘制左右两根旗子

这里需要根据path来绘制,需要手动一点点调试样式

// 旗杆本身的path路径
export const brushHandlePath = (height) => {
  return [
    // handle
    `M 0 0`, // move to origin
    `v ${height}`, // vertical line
    // 'm 2 0', // move 1px to the right
    // `V 0`, // second vertical line
    `M 0 1`, // move to origin
    // head
    'h 10', // horizontal line
    'q 1 0, 1 1', // rounded corner
    'v 22', // vertical line
    'q 0 1 -1 1', // rounded corner
    'h -10', // horizontal line
    `z`, // close path
  ].join(' ');
};
// 旗杆头部填充的两根白色竖条的路径
export const brushHandleAccentPath = () => {
  return [
    'M 0 -3', // move to origin
    'm 3 7', // move to first accent
    'v 18', // vertical line
    'M 0 -3', // move to origin
    'm 8 7', // move to second accent
    'v 18', // vertical line
    'z',
  ].join(' ');
};
// 使用上面填好的path路径
const Handle = ({ color, d }) => {
  return (
    <path
      d={d}
      stroke={color}
      strokeWidth="2.5"
      fill={color}
      cursor="ew-resize"
      pointerEvents="none"
    />
  );
};

7.一切就绪,组装各UI模块

此时UI层面已经大功告成,就可以各模块组合看效果了

    // svg : 整个画布布局宽度、高度设置
     <svg  
        width="100%"
        height="100%"
        viewBox={`0 0 ${width} ${height}`}
        style={{ overflow: 'visible' }}
      >
       <defs>
       
          // brushDomain 就是两根旗子选中的范围,算出两个旗子的范围之差,然后截取出高亮的面积图
          {brushDomain && (
            // mask to highlight selected area
            <clipPath id={`${id}-chart-area-mask`}>
              <rect
                fill="white"
                x={xScale(brushDomain[0])}
                y={0}
                width={xScale(brushDomain[1]) - xScale(brushDomain[0])}
                height={innerHeight}
              />
            </clipPath>
          )}
        </defs>
        <g transform={`translate(${margins.left},${margins.top})`}>
          <g clipPath={`url(#${id}-chart-clip)`}>
            // 这是面积图
            <Area
              series={series}
              xScale={xScale}
              yScale={yScale}
              xValue={xAccessor}
              yValue={yAccessor}
              // fill="#78DE9D"
              fill="var(--okd-color-green-200)"
            />
            // 这里又绘制了一次面积图,原因是:两根旗子之间选中的流动性需要高亮,所以需要再绘制一个高亮颜色的面积图,并且 clipth = {`url(#${id}-chart-area-mask)`} 与上面的cliptath就对应上了。
            {brushDomain && (
              // duplicate area chart with mask for selected area
              <g clipPath={`url(#${id}-chart-area-mask)`}>
                <Area
                  series={series}
                  xScale={xScale}
                  yScale={yScale}
                  xValue={xAccessor}
                  yValue={yAccessor}
                  fill="var(--okd-color-green-500)"
                />
              </g>
            )}
            // 表示当前价格的一条竖线
            <Line value={current} xScale={xScale} innerHeight={innerHeight} />
            // X轴坐标轴
            <AxisBottom xScale={xScale} innerHeight={innerHeight} />
          </g>
           // 放大缩小
          <ZoomOverlay width={innerWidth} height={height} ref={zoomRef} />
          // 两根旗子
          <Brush
            id={id}
            xScale={xScale}
            interactive
            brushLabelValue={brushLabels}
            brushExtent={brushDomain ?? (xScale && xScale.domain())}
            innerWidth={innerWidth}
            innerHeight={innerHeight}
            setBrushExtent={onBrushDomainChange}
            westHandleColor="#31BD65"
            eastHandleColor="#31BD65"
          />
        </g>
      </svg>

8.静态部分完成,旗子可以动起来了

需要使用d3.brushX()函数:一维画笔X-尺寸。同样有brushY()函数:一维画笔Y-尺寸(价格图使用这个方法)

用法:d3.brushX();

参数:该函数不接受任何参数。 返回值:此函数沿x轴返回新创建的一维笔刷

  1、d3设置一维画笔
  brushBehavior.current = brushX()
      .extent([
        [Math.max(0 + BRUSH_EXTENT_MARGIN_PX, xScale(0)), 0],
        [innerWidth - BRUSH_EXTENT_MARGIN_PX, innerHeight],
      ]) //  extent 设置可刷取的范围
      .handleSize(30) // 设置brush柄的大小、默认为6
      .on('brush end', brushed); // 滑动结束事件,brushed里可以定义业务回调
    brushBehavior.current(select(brushRef.current)); // 选中的元素
    

2、画笔动作完成后,处理数据
  const onBrushDomainChange = useCallback((domain, mode) => {
    let leftRangeValue = Number(domain[0]);
    let rightRangeValue = Number(domain[1]);
    if (leftRangeValue <= 0) {
      leftRangeValue = 1 / 10 ** 18;
    }
    if (rightRangeValue > 1e35) {
      rightRangeValue = 1e35;
    }
    setLocalBrushExtent([leftRangeValue, rightRangeValue]);

    // 拖拽柱子时,handle:单根拖拽, drag:两个一起拖拽
    if (mode === 'handle' || mode === 'drag') {
      const { minPrice, maxPrice } = priceRange;

      // 左侧价格变化(minPrice)
      if (!compareIsEqualPrice(minPrice, leftRangeValue)) {
        // transformPriceOnTickPoint 把价格转化在整tick点上
        const { price, tick } = transformPriceOnTickPoint(
          leftRangeValue,
          false
        );
        // 改变下方价格输入框的值,以及和下单器询价联动
        onLeftRangeInput(tick, price);
      }

      //右侧价格变化(maxPrice)
      if (!compareIsEqualPrice(maxPrice, rightRangeValue)) {
        const { price, tick } = transformPriceOnTickPoint(
          rightRangeValue,
          mode === 'handle'
        );
        onRightRangeInput(tick, price);
      }
      updatePros({
        hasChangePrice: true,
      });
    }

    // 初始化时、点击重置价格区间时
    if (mode === 'reset' || mode === 'init') {
      if (leftRangeValue > 0) {
        const { price, tick } = transformPriceOnTickPoint(
          leftRangeValue,
          false
        );
        onLeftRangeInput(tick, price);
      }

      if (rightRangeValue > 0) {
        const { price, tick } = transformPriceOnTickPoint(
          rightRangeValue,
          mode !== 'init'
        );
        onRightRangeInput(tick, price);
      }
    }
    // 汇率反转时
    if (mode === 'reverse') {
      if (leftRangeValue > 0) {
        setPriceRange(PRICE_TYPE.MIN_PRICE, leftRangeValue);
      }
      if (rightRangeValue > 0) {
        setPriceRange(PRICE_TYPE.MAX_PRICE, rightRangeValue);
      }
    }
  });

  3、onRightRangeInput(以右侧价格为例) 价格输入框、下单器开始联动起来  
  const onRightRangeInput = (tickValue: number, priceValue: number) => {
    let rightInputTick = tickValue;
    let rightInputPrice = priceValue;
    
    const currentLeftRangeTick = isReverse
      ? -tickRange.tickUpper
      : tickRange.tickLower;
      // 判断滑动左右两个旗子的tick是否相同,如果是增加一个tickSpacing
    if (rightInputTick == currentLeftRangeTick) {
      rightInputTick += tickSpacing;
      rightInputPrice = getPriceByTick(
        rightInputTick,
        token0Precisions!,
        token1Precisions!
      );
    }

    // 更新价格范围-最大的价格
    uniV3SubscribeStore.setPriceRange(PRICE_TYPE.MAX_PRICE, rightInputPrice);
    
    // 判断是否是反转,决定更新tick的范围
    const tickType = isReverse ? TICK_TYPE.TICK_LOWER : TICK_TYPE.TICK_UPPER;
    // 更新tick
    uniV3SubscribeStore.setTickRange(
      tickType,
      isReverse ? -rightInputTick : rightInputTick
    );
    judgeOneSidedLiquidity(); // 判断单边流动性, 下单器是否投资单币、双币
    debounceV3ReceiveInfo(); // 根据最终的tick范围,开始询价
  };    

9.总结

以上就是深度图从UI层一步步的绘制,再到滑动旗杆改变价格,再计算得出tick范围,最终在下单器询价的整体流程