zzxworld

使用 D3.js(v7) 绘制 K 线蜡烛图

学习 D3.js 的第 5 天,今天来个更有挑战性的项目:用 D3.js 画炒股软件中常见的 K 线图。

开始之前同样继续友情提示一下,本文是对 D3.js 的进阶使用,如果还不了解 D3 的基础概念和使用流程,请先浏览我前面刚开始的几篇入门级文章:

有了上面几篇文章的基础后,会有利于理解本文中是使用的绘图代码。

什么是 K 线图

K 线又叫「蜡烛图」,所以只是叫法不同,本质上是同一种事物。据说起源于日本卖大米。我对它的来历没多大兴趣,有需要的朋友可以自己去了解。不过这种图特殊的数据展现能力非常吸引我。这也是我今天想要画它的原因。

一个 K 线通常由 4 个必不可少的元素组成:

  1. 开始值
  2. 结束值
  3. 最高值
  4. 最低值

这 4 个元素可以根据具体情况换成任何名词。比如在股市,就是开盘价格,收盘价格,最高价格和最低价格。但如果是针对每天的温度,也可以替换为早上温度,晚上温度,最高温度和最低温度。所以不要认为 K 线只能应用于股市。只要是有从这 4 个方面来展示数据的需求,都可以采用 K 线图。

那么如何通过图形来展现这 4 项数据呢? 来看个例子:

收价高于开价的 K 线图 收盘价高于开盘价的 K 线图

从图片中的名字就可以看出,我这是在以股市举例。这种空心的 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)
})

看看蜡烛图画上后的效果:

沪深300K线图

是不是像那么回事了?本来以本文的目的,到这里就可以结束了。不过我还想更进一步,添加一点交互效果。

添加交互效果

我像添加的交互效果主要有两点:

  1. 有两条跟随鼠标移动的「十字线」,但这个跟随又不是鼠标一动它就动的效果。需要靠近相关的 K 线后才变动位置。
  2. 当鼠标移到相应的 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)
})

最后看看效果:

带鼠标效果的 K 线图

可惜我没找到顺手的动画截图工具,实际效果还是很酷的。