记录和分享一个好用的k线图js库tradingview/lightweight-charts

1,780 阅读7分钟

pc端股票k线图.png

tradingview这个k线图组件分轻量版和高级版,轻量版开源免费,高级版也是免费的,但是需要在官网提交表单申请(需要填写公司名称,地址,公司产品链接,license签字),高级版和轻量版的区别是高级版支持辅助线,图表作图,添加记号等高级功能。如果只是需要画k线图的话,那轻量版就完全够用。 轻量版tradingview/lightweight-charts的github链接:github.com/tradingview…

具体使用的代码,我就直接贴上我在工作项目里写的代码了,代码里的注释写的非常详细。

<!--  https://github.com/tradingview/lightweight-charts -->
<template>
  <div class="wrapper" v-loading="loading" element-loading-background="rgba(0, 0, 0, 0.6)">
    <div v-if="!isEmpty && !loading" id="kline-chart"></div>
    <div v-else-if="isEmpty" style="height: 100%; display: flex; justify-content: center; align-items: center">
      No K-line data available for the selected time period
    </div>
  </div>
</template>

<script setup lang="ts">
import { createChart } from 'lightweight-charts'
import { getRetifiveKLine } from '@/api/market'
import { getLanguageError, formatD } from '@/utils/util'
import dayjs from 'dayjs'

const props = defineProps({
  stockCode: {
    type: String,
    default: '',
  },
  tabIndex: {
    type: Number,
    default: 0,
  },
  currentStock: {
    type: Object,
    default: {},
  },
  sourceType: {
    type: Number,
    default: 4,
  },
  // 图表类型, 默认蜡烛图, 可选line area等
  chartType: {
    type: String,
    default: 'Candles',
  },
})

const loading = ref(false) // loading加载动效
let isFetch = false // 是否正在加载时间范围数据, 避免用户向左拖拽图表时大量发送请求
const isEmpty = ref(false) // 后端没有返回k线数据时,需要显示文本

let histogramData = [] // 下面的交易量柱状图数据
let candleData = [] // 蜡烛图数据(默认)
let lineData = [] // 折线图数据(切换成line-chart用到)
let areaData = [] // 区块图数据(切换成area-chart用到)
let barData = [] // 柱状图数据(切换成bar-chart用到)

let histogramSeries = null // 下方交易量柱状图系列
let candlestickSeries = null // 蜡烛图系列
let lineSeries = null // 折线图系列
let areaSeries = null // 区块图系列
let barSeries = null // 柱状图系列

let endTime = dayjs() // 发送请求的endtime(每次新加载数据的最后一天)
let dateRangeLength = 30 // 发送请求的时间范围长度, 根据不同维护时间范围是不同的, 见resetParams(单位是天)
let resolution = 'H' // 分时图维度参数
let chart = null // 图表对象

onMounted(() => {
  resetParams()
  loading.value = true
  refreshKLineData()
    .then(() => {
      setTimeout(() => {
        drawChart()
      })
    })
    .finally(() => {
      loading.value = false
    })
})

// 发送请求获取k线数据
const refreshKLineData = () => {
  return new Promise((resolve, reject) => {
    const params = {
      symbol: props.stockCode,
      resolution: resolution,
      starttime: endTime.add(-dateRangeLength, 'd').format('YYYY-MM-DD'),
      endtime: endTime.format('YYYY-MM-DD'),
    }

    getRetifiveKLine(params).then((response) => {
      loading.value = false

      // 获取k线失败的处理
      if (response.status !== 0) {
        ElMessage.error(getLanguageError('market.klinefail'))
        reject(getLanguageError('market.klinefail'))
        return
      }

      const resData = response.data

      // 对于月k线和周k线, endTime只减去1天会出现数据重复的问题, 需要减掉1个月(周k线是1周)
      if (props.tabIndex === 3) {
        endTime = endTime.add(-dateRangeLength, 'd').add(-1, 'M')
      }
      if (props.tabIndex === 2) {
        endTime = endTime.add(-dateRangeLength, 'd').add(-1, 'w')
      } else {
        endTime = endTime.add(-dateRangeLength - 1, 'd') // 最新时间更新, 等用户拖拽到左侧时以这个时间作为最新时间继续加载
      }
      isEmpty.value = resData.t.length === 0 && candleData.length === 0

      if (isEmpty.value) {
        reject('no data return')
        return
      }

      // 由于后端传过来的数据是从新到旧, k线图组件的数据顺序必须是从旧到新, 所以做reverse处理
      const sortedRes = {
        c: resData.c.reverse(),
        h: resData.h.reverse(),
        l: resData.l.reverse(),
        o: resData.o.reverse(),
        v: resData.v.reverse(),
        t: resData.t.reverse(),
      }

      // 为各个图表类型的数据分别赋上值
      const newCandleData = []
      const newHistogramData = []
      const newLineData = []
      const newAreaData = []
      const newBarData = []
      sortedRes.t.forEach((item, i) => {
        newCandleData.push({
          time: sortedRes.t[i],
          open: sortedRes.o[i],
          high: sortedRes.h[i],
          low: sortedRes.l[i],
          close: sortedRes.c[i],
          volume: sortedRes.v[i],
        })
        newBarData.push({
          time: sortedRes.t[i],
          open: sortedRes.o[i],
          high: sortedRes.h[i],
          low: sortedRes.l[i],
          close: sortedRes.c[i],
          volume: sortedRes.v[i],
        })
        newHistogramData.push({
          time: sortedRes.t[i],
          value: sortedRes.v[i],
        })
        newLineData.push({
          time: sortedRes.t[i],
          value: sortedRes.c[i],
        })
        newAreaData.push({
          time: sortedRes.t[i],
          value: sortedRes.c[i],
        })
      })
      candleData = [...newCandleData, ...candleData]
      lineData = [...newLineData, ...lineData]
      areaData = [...newAreaData, ...areaData]
      barData = [...newBarData, ...barData]
      histogramData = [...newHistogramData, ...histogramData]

      resolve()
    })
  })
}

// 绘制k线图
const drawChart = () => {
  const chartOptions = {
    layout: { textColor: '#aaa', background: { type: 'solid', color: '#222' } }, // 文字颜色为白色, 背景为黑色
    grid: { vertLines: { color: '#333' }, horzLines: { color: '#333' } }, // 横轴纵轴的分割线颜色
  }

  chart = createChart(document.getElementById('kline-chart'), chartOptions)

  // 把当前类型的图表对象加载到图表中
  setChartSeries(props.chartType)
  setSeriesData(props.chartType)

  // 下方交易量图
  histogramSeries = chart.addHistogramSeries({
    color: '#26A69A',
    priceLineWidth: 0,
    baseLineWidth: 0,
    priceScaleId: 'left',
  })
  histogramSeries.setData(histogramData)

  chart.applyOptions({
    crosshair: {
      // 隐藏鼠标悬浮时横纵轴默认显示的文字
      vertLine: {
        labelVisible: false,
      },
    },
    timeScale: {
      // format横轴时间显示
      tickMarkFormatter: (time, index) => {
        if (props.tabIndex === 0) {
          return formatD(time * 1000, 'dd-MM hh:mm')
        } else {
          return formatD(time * 1000, 'dd-MM-yyyy')
        }
      },
    },
  })

  chart.priceScale('left').applyOptions({
    scaleMargins: {
      top: 0.75, // 左侧是交易量的轴, 最高点距离顶部有75%的距离
      bottom: 0,
    },
  })
  chart.priceScale('right').applyOptions({
    scaleMargins: {
      top: 0.1, // 右侧是k线价格的轴, 最高点距离顶部有10%的距离
      bottom: 0.35, // 最低点距离低部有35%的距离
    },
  })

  // 组件没有自带tooltip, 按照官网教程实现, 代码较长用大括号包起来方便折叠
  // https://tradingview.github.io/lightweight-charts/tutorials/how_to/tooltips#tracking-tooltip
  {
    const container = document.getElementById('kline-chart')
    const toolTipWidth = 200 // tooltip宽度
    const toolTipHeight = 200 // tooltip高度
    const toolTipMargin = 0

    // Create and style the tooltip html element
    const toolTip = document.createElement('div')
    toolTip.style = `width: ${toolTipWidth}px; height: ${toolTipHeight}px; position: absolute; display: none; padding: 8px; box-sizing: border-box; font-size: 12px; text-align: left; z-index: 1000; top: 12px; left: 12px; pointer-events: none; border: 1px solid; border-radius: 2px;font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;`
    toolTip.style.background = 'white'
    toolTip.style.color = 'black'
    toolTip.style.borderColor = 'rgba( 38, 166, 154, 1)'
    container.appendChild(toolTip)

    // update tooltip
    chart.subscribeCrosshairMove((param) => {
      if (
        param.point === undefined ||
        !param.time ||
        param.point.x < 0 ||
        param.point.x > container.clientWidth ||
        param.point.y < 0 ||
        param.point.y > container.clientHeight
      ) {
        toolTip.style.display = 'none'
      } else {
        toolTip.style.display = 'block'
        const dataItem = candleData.find((item) => item.time === param.time)
        const data = param.seriesData.get(getSeriesByChartType(props.chartType))

        toolTip.innerHTML = `
        <div style="color: ${'rgba( 38, 166, 154, 1)'}; font-size: 20px;">
          ${props.currentStock.stockName}
        </div>
        <div style="font-size: 16px; margin: 1px 0px; color: ${'black'}">
          Opening: ${dataItem.open}
        </div>
        <div style="font-size: 16px; margin: 1px 0px; color: ${'black'}">
          Closing: ${dataItem.close}
        </div>
        <div style="font-size: 16px; margin: 1px 0px; color: ${'black'}">
          Highest: ${dataItem.high}
        </div>
        <div style="font-size: 16px; margin: 1px 0px; color: ${'black'}">
          Lowest: ${dataItem.low}
        </div>
        <div style="font-size: 16px; margin: 1px 0px; color: ${'black'}">
          Volume: ${dataItem.volume}
        </div>
        <div style="font-size: 16px; margin: 1px 0px; color: ${'black'}">
          Time: ${
            props.tabIndex === 0
              ? formatD(dataItem.time * 1000, 'dd-MM-yyyy hh:mm')
              : formatD(dataItem.time * 1000, 'dd-MM-yyyy')
          }
        </div>`

        const y = param.point.y
        let left = param.point.x + toolTipMargin
        if (left > container.clientWidth - toolTipWidth) {
          left = param.point.x - toolTipMargin - toolTipWidth
        }

        let top = y + toolTipMargin
        if (top > container.clientHeight - toolTipHeight) {
          top = y - toolTipHeight - toolTipMargin
        }
        toolTip.style.left = left + 'px'
        toolTip.style.top = top + 'px'
      }
    })
  }

  chart.timeScale().fitContent()
  chart.timeScale().subscribeVisibleLogicalRangeChange((logicalRange) => {
    // 最左侧的数据距离用户拖拽后的起点位置如果小于30个数据单位, 则发起请求加载更早的数据
    if (logicalRange.from < 30 && !isFetch) {
      // 加载更多数据
      isFetch = true
      refreshKLineData().then(() => {
        setSeriesData(props.chartType)
        histogramSeries.setData(histogramData)
        isFetch = false
      })
    }
  })
}

// 初始化solution和时间范围的参数
const resetParams = () => {
  endTime = dayjs()
  candleData = []
  histogramData = []
  switch (props.tabIndex) {
    // 0代表小时分时图
    case 0:
      resolution = 'H'
      dateRangeLength = 30
      break
    // 1代表天分时图
    case 1:
      resolution = 'D'
      dateRangeLength = 120
      break
    // 2代表周分时图
    case 2:
      resolution = 'W'
      dateRangeLength = 420
      break
    // 3代表月分时图
    case 3:
      resolution = 'M'
      dateRangeLength = 1800
      break
    default:
      break
  }
}

// 根据图表类型获取图表集对象
const getSeriesByChartType = (chartType) => {
  switch (chartType) {
    case 'Candles':
      return candlestickSeries
      break
    case 'Line':
      return lineSeries
      break
    case 'Bars':
      return barSeries
      break
    case 'Area':
      return areaSeries
      break
    default:
      break
  }
}

// 加载图表
const setChartSeries = (chartType) => {
  switch (chartType) {
    case 'Candles':
      candlestickSeries = chart.addCandlestickSeries({
        upColor: '#26a69a',
        downColor: '#ef5350',
        borderVisible: false,
        wickUpColor: '#26a69a',
        wickDownColor: '#ef5350',
        priceScaleId: 'right',
      })
      break
    case 'Line':
      lineSeries = chart.addLineSeries({
        color: '#2962FF',
        priceScaleId: 'right',
      })
      break
    case 'Bars':
      barSeries = chart.addBarSeries({
        options: { upColor: '#26a69a', downColor: '#ef5350', priceScaleId: 'right' },
      })
      break
    case 'Area':
      areaSeries = chart.addAreaSeries({
        options: {
          lineColor: '#2962FF',
          topColor: '#2962FF',
          bottomColor: 'rgba(41, 98, 255, 0.28)',
          priceScaleId: 'right',
        },
      })
      break
    default:
      break
  }
}

// 给图表系列设置数据
const setSeriesData = (chartType) => {
  switch (chartType) {
    case 'Candles':
      candlestickSeries.setData(candleData)
      break
    case 'Line':
      lineSeries.setData(lineData)
      break
    case 'Bars':
      barSeries.setData(barData)
      break
    case 'Area':
      areaSeries.setData(areaData)
      break
    default:
      break
  }
}

// 用户切换图表类型时, 需要卸载旧的series换上新的
watch(
  () => props.chartType,
  (newValue, oldValue) => {
    if (chart) {
      chart.removeSeries(getSeriesByChartType(oldValue))
      setChartSeries(newValue)
      setSeriesData(newValue)
    }
  }
)
</script>
<style lang="scss" scoped>
.wrapper {
  width: 100%;
  height: calc(100vh - 420px);
  position: relative;
  #kline-chart {
    width: 100%;
    height: calc(100vh - 420px);
    position: relative;
  }
}
</style>

另外我也研究过这个组件能否用在移动端上,首先lightweight-charts用在h5是完全没有问题的,代码和web端完全一致,直接使用即可。但是如果是用在uniapp的app端上的话,因为图表挂载到dom的写法在app端上是行不通的,所以直接在uniapp的vue文件里用lightweight-charts不行,不过可以通过web-view加载本地html的方式来实现。

web-view引入本地项目中的html文件,vue-cli创建的项目,web-view的html文件需要放在static目录下。

<web-view src="/static/hybrid/index.html"></web-view>

在html文件中引入js库画k线图。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no"
    />
    <title>k线图示例</title>
    <style type="text/css">
      body {
        margin: 0;
      }
      #my-charts {
        width: 100%;
        height: 200px;
      }
    </style>
  </head>
  <body>
    <div id="my-charts"></div>
    <script type="text/javascript" src="./lightweight-charts.js"></script>
    <script>
      const chartOption = {
        layout: { textColor: '#aaa', background: { type: 'solid', color: '#222' } }, // 文字颜色为白色, 背景为黑色
        grid: { vertLines: { color: '#333' }, horzLines: { color: '#333' } }, // 横轴纵轴的分割线颜色
      }
      const chart = LightweightCharts.createChart(document.getElementById('my-charts'), chartOption)
      const lineSeries = chart.addLineSeries({
        color: '#2962FF',
      })
      lineSeries.setData([
        { time: '2019-04-01', value: 40.01 },
        { time: '2019-04-02', value: 96.63 },
        { time: '2019-04-03', value: 76.64 },
        { time: '2019-04-04', value: 21.89 },
        { time: '2019-04-05', value: 74.43 },
        { time: '2019-04-06', value: 80.01 },
        { time: '2019-04-07', value: 96.63 },
        { time: '2019-04-08', value: 46.64 },
        { time: '2019-04-09', value: 81.89 },
        { time: '2019-04-10', value: 74.43 },
        { time: '2019-04-11', value: 80.01 },
        { time: '2019-04-12', value: 46.63 },
        { time: '2019-04-13', value: 76.64 },
        { time: '2019-04-14', value: 81.89 },
        { time: '2019-04-15', value: 74.43 },
        { time: '2019-04-16', value: 80.01 },
        { time: '2019-04-17', value: 96.63 },
        { time: '2019-04-18', value: 26.64 },
        { time: '2019-04-19', value: 41.89 },
        { time: '2019-04-20', value: 74.43 },
        { time: '2019-04-21', value: 80.01 },
        { time: '2019-04-22', value: 176.63 },
        { time: '2019-04-23', value: 76.64 },
        { time: '2019-04-24', value: 11.89 },
        { time: '2019-04-25', value: 74.43 },
        { time: '2019-04-26', value: 90.01 },
        { time: '2019-04-27', value: 96.63 },
        { time: '2019-04-28', value: 76.64 },
        { time: '2019-04-29', value: 81.89 },
        { time: '2019-04-30', value: 123.43 },
      ])
      chart.timeScale().fitContent()
    </script>
  </body>
</html>

image.png