深度图和价格图都是基于D3.js库来绘制的svg图形
D3.js (Data-Driven Documents) 是一个用于操作文档的JavaScript库,它可以通过使用HTML, SVG 和 CSS等技术在网页上动态生成数据可视化。下面跟着以下步骤实现深度图的绘制:
1.数据集定义
首先我们需要定义好我们要展示的数据,看下将要用到的数据集:liquidity表示y轴数据,token0Price表示x轴数据
css
[ { "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 }像素的空白的画布
ini
const margins = { top: 20, right: 2, bottom: 20, left: 0 };
const width = 400
const height = 200
3.定义比例尺
根据数据集中的数值范围,我们需要创建与之对应的比例尺。使用scaleLinear()函数分别创建x轴和y轴的比例尺:
使用domain()方法来设置比例尺的输入域(即数据范围),使用range()方法来确定输出范围(在画布上的位置)。
scss
// 计算好绘制面积
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提供的一种曲线类型定义。
javascript
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一样设置刻度的样式
javascript
//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来绘制,需要手动一点点调试样式
javascript
// 旗杆本身的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层面已经大功告成,就可以各模块组合看效果了
ini
// 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轴返回新创建的一维笔刷
scss
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范围,最终在下单器询价的整体流程