学习 D3.js 的第 5 天,今天来个更有挑战性的项目:用 D3.js 画炒股软件中常见的 K 线图。
开始之前同样继续友情提示一下,本文是对 D3.js 的进阶使用,如果还不了解 D3 的基础概念和使用流程,请先浏览我前面刚开始的几篇入门级文章:
有了上面几篇文章的基础后,会有利于理解本文中是使用的绘图代码。
什么是 K 线图
K 线又叫「蜡烛图」,所以只是叫法不同,本质上是同一种事物。据说起源于日本卖大米。我对它的来历没多大兴趣,有需要的朋友可以自己去了解。不过这种图特殊的数据展现能力非常吸引我。这也是我今天想要画它的原因。
一个 K 线通常由 4 个必不可少的元素组成:
- 开始值
- 结束值
- 最高值
- 最低值
这 4 个元素可以根据具体情况换成任何名词。比如在股市,就是开盘价格,收盘价格,最高价格和最低价格。但如果是针对每天的温度,也可以替换为早上温度,晚上温度,最高温度和最低温度。所以不要认为 K 线只能应用于股市。只要是有从这 4 个方面来展示数据的需求,都可以采用 K 线图。
那么如何通过图形来展现这 4 项数据呢? 来看个例子:
收盘价高于开盘价的 K 线图
从图片中的名字就可以看出,我这是在以股市举例。这种空心的 K 线也被称为「阳线」,特征是结束的值高于开始的值,表示数值在单位时间里增加了。影射到股市也就是涨了,通常用红色表示.
开盘价高于收盘价的 K 线图
这种实心的 K 线被称为「阴线」,特征是结束值低于开始值。表示数值在单位时间里降低了。对应到股市就是跌了,通常用绿色表示。
而最高值和最低值,则是以一根贯穿其中的细线表示。高在上,低在下,除了颜色和上面的宽线保持一致,没有任何特殊和例外。
准备数据
了解了 K 线的理论知识,就可以开始着手画图了。不过画之前需要先准备点数据。
我直接找了个股市接口,获取了一份从今年能 1 月份开始的「沪深300」K 线数据。它的数据格式是这样的:
{
"name":"沪深300",
"data":[
["20220104",4957.98,4917.77,4961.45,4874.53,15153477600,-0.46],
["20220105",4907.94,4868.12,4916.28,4851.98,17881610000,-1.01],
// ...
]
}
data
部分的数据经过对比,按顺序可以确定他们的名称如下:
- 第一个一目了然是日期,最后一个为当天的涨跌比。
- 倒数第二个整数貌似是当天的交易量。这里用不上,不用在意。
- 中间这四个就是画 K 线需要的数据,按照顺序分别如下:开盘价,收盘价,最高价,最低价。
把这份数据保存为 data.json 即完成了数据准备工作。接下来开始本文的主题:画 K 线图。
画坐标轴
在前面几个图的绘制过程中,我用的「面条式」代码写法,从这个图开始,因为复杂度有所提高,我要开始用函数封装写法了。先来看看完成坐标轴绘制的代码:
const width = 1000
const height = 500
const margin = {top: 50, right: 30, bottom: 30, left: 80}
const svg = d3.select('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height])
// 计算蜡烛图实线宽度
const getCandlestickWidth = dataLength => (width-margin.left-margin.right)/dataLength-3
// 绘制标题
function drawTitle(value) {
const title = svg.append('text')
.text(value)
.attr('x', margin.left)
.attr('y', margin.top/2)
.attr('text-anchor', 'start')
.attr('dominant-baseline', 'hanging')
}
// 绘制横坐标
function drawAxisX(data) {
const dates = d3.map(data, v => v[0])
const scale = d3.scaleLinear()
.domain([0, data.length])
.range([0, width-margin.left-margin.right])
const axis = d3.axisBottom(scale)
.ticks(10)
.tickFormat(v => {
return dates[v]
})
svg.append('g')
.attr('transform', 'translate('+margin.left+','+(height-margin.bottom)+')')
.call(axis)
return scale
}
// 绘制竖标轴
function drawAxisY(data) {
// 找到最高价和最低价,用来作为蜡烛图的参照坐标
const highPrices = d3.map(data, v => v[3])
const lowPrices = d3.map(data, v => v[4])
const pricePending = Math.round(d3.max(highPrices) / 100)
// 绘制竖坐标
const scale = d3.scaleLinear()
.domain([d3.min(lowPrices)-pricePending, d3.max(highPrices)+pricePending])
.range([0, height-margin.top-margin.bottom])
const axis = d3.axisLeft(scale).ticks(10)
svg.append('g')
.attr('transform', 'translate('+(margin.left-5)+', '+margin.top+')')
.call(axis)
.call(g => g.select('.domain').remove())
.call(g => {
g.selectAll('.tick line')
.clone()
.attr('stroke-opacity', 0.1)
.attr('stroke-dasharray', 5)
.attr('x2', width-margin.left-margin.right)
})
return scale
}
// 获取数据并开始绘制
d3.json('/data.json').then(data => {
const xScale = drawAxisX(data.data)
const yScale = drawAxisY(data.data)
drawTitle(data.name)
})
这次使用不同的数据来源方式,依靠 D3 提供的 json 文件网络获取功能,没有感觉到一点阻碍。坐标轴的绘制上比之前略微复杂了一点,主要来自以下需求:
- 为了方便对比有时间相隔的 K 线,需要在纵坐标上画出浅色的水平参考线。这通过
call
方法编辑已存在标签的方式解决。 pricePending
这个是为了避免绘制出来的 K 线压着上下边显示。scaleTime
本来是时间横向坐标最佳的标尺方法,但它生成的坐标时间是连续的。而股市的时间因为假期的原因不是连续的,这导致画出来的 K 线图在碰到假期时会显示的不连续。所以退而求次使用了scaleLinear
线性标尺。然后再通过 Tick 功能自定义坐标轴上的刻度和时间显示。
完成标尺绘制的图表效果如下:
画蜡烛图
蜡烛图的绘制方法也封装到了一个函数中:
function drawCandlestick(data, xScale, yScale) {
// 处理蜡烛图边框颜色
const handleStrokeColor = (v, i) => {
if (v[1] > v[2]) {
return 'green'
}
return 'red'
}
// 计算蜡烛图实线宽度
const candlestickWidth = getCandlestickWidth(data.length)
const g = svg.append('g')
.attr('transform', 'translate('+margin.left+', '+margin.top+')')
const candlestick = g.selectAll('g')
.data(data)
.enter()
.append('g')
candlestick.append('line')
.attr('x1', (v, i) => {
return xScale(i)+candlestickWidth/2
})
.attr('y1', (v, i) => {
return height - yScale(v[3]) - margin.top - margin.bottom
})
.attr('x2', (v, i) => {
return xScale(i)+candlestickWidth/2
})
.attr('y2', (v, i) => {
return height - yScale(v[4]) - margin.top - margin.bottom
})
.attr('stroke', handleStrokeColor)
.attr('stroke-width', 1)
// 绘制蜡烛图实线
candlestick.append('rect')
.attr('width', candlestickWidth)
.attr('height', (v, i) => {
return Math.abs(yScale(v[1]) - yScale(v[2]))
})
.attr('x', (v, i) => {
return xScale(i)
})
.attr('y', (v, i) => {
return height - yScale(d3.max([v[1], v[2]])) - margin.top - margin.bottom
})
.attr('rx', 1)
.attr('stroke', handleStrokeColor)
.attr('fill', (v, i) => {
if (v[1] > v[2]) {
return 'green'
}
return 'white'
})
}
函数需要三个参数:数据,X 轴标尺和 Y 轴标尺。在数据获取的代码中加入这个函数:
d3.json('/data.json').then(data => {
const xScale = drawAxisX(data.data)
const yScale = drawAxisY(data.data)
drawCandlestick(data.data, xScale, yScale)
drawTitle(data.name)
})
看看蜡烛图画上后的效果:
是不是像那么回事了?本来以本文的目的,到这里就可以结束了。不过我还想更进一步,添加一点交互效果。
添加交互效果
我像添加的交互效果主要有两点:
- 有两条跟随鼠标移动的「十字线」,但这个跟随又不是鼠标一动它就动的效果。需要靠近相关的 K 线后才变动位置。
- 当鼠标移到相应的 K 线上时,在顶部显示其相关的信息。
来看看实现这样两个效果的代码:
function drawFocusLayout(data, xScale, yScale) {
// 计算蜡烛图实线宽度
const candlestickWidth = getCandlestickWidth(data.length)
// 鼠标移入事件
const handleMouseOver = function (e) {
d3.select('#focusLineX').attr('display', '')
d3.select('#focusLineY').attr('display', '')
}
// 鼠标在图表中移动事件
const handleMouseMove = function (e) {
const [mx, my] = d3.pointer(e)
const i = d3.bisectCenter(data.map((v, i) => i), xScale.invert(mx-margin.left));
const px = xScale(i) + margin.left + candlestickWidth/2
const py = height - yScale(data[i][2]) - margin.bottom
d3.select('#focusLineX').attr('x1', px).attr('x2', px)
d3.select('#focusLineY').attr('y1', py).attr('y2', py)
text.text(formatText(data[i]))
}
// 鼠标移出事件
const handleMouseOut = function (e) {
d3.select('#focusLineX').attr('display', 'none')
d3.select('#focusLineY').attr('display', 'none')
text.text(formatText(data[data.length-1]))
}
const formatText = (v) => {
return `${v[0].replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3')}
涨跌幅: ${v[6]}% |
开盘: ${v[1]} |
收盘: ${v[2]} |
最高: ${v[3]} |
最低: ${v[4]}`
}
// 绘制数据提示信息
const text = svg.append('text')
.attr('x', width-margin.right)
.attr('y', margin.top/2)
.attr('font-size', '0.85em')
.attr('fill', '#666')
.attr('text-anchor', 'end')
.attr('dominant-baseline', 'hanging')
.text(formatText(data[data.length-1]))
// 绘制标识线
svg.append('line')
.attr('id', 'focusLineX')
.attr('x1', margin.left)
.attr('y1', margin.top)
.attr('x2', margin.left)
.attr('y2', height-margin.bottom)
.attr('stroke', 'steelblue')
.attr('stroke-width', 1)
.attr('opacity', 0.5)
.attr('display', 'none')
svg.append('line')
.attr('id', 'focusLineY')
.attr('x1', margin.left)
.attr('y1', margin.top)
.attr('x2', width-margin.right)
.attr('y2', margin.top)
.attr('stroke', 'steelblue')
.attr('stroke-width', 1)
.attr('opacity', 0.5)
.attr('display', 'none')
// 绘制鼠标事件触发区域
svg.append('rect')
.attr('x', margin.left)
.attr('y', margin.top)
.attr('width', width-margin.left-margin.right)
.attr('height', height-margin.top-margin.bottom)
.attr('opacity', 0)
.on('mouseover', handleMouseOver)
.on('mousemove', handleMouseMove)
.on('mouseout', handleMouseOut)
}
功能点虽然不多,但代码量却不少。这段代码的核心主要就在这一句:
const i = d3.bisectCenter(data.map((v, i) => i), xScale.invert(mx-margin.left));
它的功能是从鼠标当前的横向坐标位置反推出最相邻的 K 线坐标。
在末尾的数据读取代码中使用这个函数:
d3.json('/data.json').then(data => {
const xScale = drawAxisX(data.data)
const yScale = drawAxisY(data.data)
drawCandlestick(data.data, xScale, yScale)
drawFocusLayout(data.data, xScale, yScale)
drawTitle(data.name)
})
最后看看效果:
可惜我没找到顺手的动画截图工具,实际效果还是很酷的。