uni-charts.vue 12.3 KB
<template>
  <view class="uni-charts">
    <canvas
        v-if="type !== 'map'"
        class="charts-canvas"
        :style="{width: width + 'px', height: height + 'px'}"
        canvas-id="uni-charts"
        @touchstart="touchStart"
        @touchmove="touchMove"
        @touchend="touchEnd"
    ></canvas>
    <view v-else class="map-container" :style="{width: width + 'px', height: height + 'px'}">
      <slot name="map"></slot>
    </view>
  </view>
</template>

<script>
export default {
  name: 'uniCharts',
  props: {
    type: {
      type: String,
      default: 'line'
    },
    data: {
      type: Array,
      default () {
        return []
      }
    },
    categories: {
      type: Array,
      default () {
        return []
      }
    },
    option: {
      type: Object,
      default () {
        return {}
      }
    },
    width: {
      type: [Number, String],
      default: 375
    },
    height: {
      type: [Number, String],
      default: 200
    }
  },
  data () {
    return {
      ctx: null,
      chartData: {},
      touchInfo: {}
    }
  },
  watch: {
    data: {
      deep: true,
      handler () {
        this.initChart()
      }
    },
    categories: {
      deep: true,
      handler () {
        this.initChart()
      }
    },
    option: {
      deep: true,
      handler () {
        this.initChart()
      }
    }
  },
  mounted () {
    this.initChart()
  },
  methods: {
    initChart () {
      if (this.type === 'kline') {
        this.drawKline()
      } else if (this.type === 'line') {
        this.drawLine()
      }
    },
    // 折线图绘制(终极版:Y轴整数 + 自适应极值 + 修复文字折叠 + 优化图例间距)
    drawLine () {
      if (!this.data.length || !this.categories.length) return;

      const ctx = uni.createCanvasContext('uni-charts', this)
      this.ctx = ctx
      const { width, height } = this
      const {
        grid = {},
        xAxis = {},
        yAxis = {},
        legend = {},
        color = ['#25AF69', '#B34C17'],
        lineSmooth = true
      } = this.option

      // 清空画布
      ctx.clearRect(0, 0, width, height)

      // 调整Grid布局,彻底解决重叠
      const gridTop = grid.top ? (typeof grid.top === 'string' ? parseFloat(grid.top) / 100 * height : grid.top) : 60
      const gridLeft = grid.left ? (typeof grid.left === 'string' ? parseFloat(grid.left) / 100 * width : grid.left) : 70
      const gridRight = grid.right ? (typeof grid.right === 'string' ? parseFloat(grid.right) / 100 * width : grid.right) : 20
      const gridBottom = grid.bottom ? (typeof grid.bottom === 'string' ? parseFloat(grid.bottom) / 100 * height : grid.bottom) : 50

      const drawWidth = width - gridLeft - gridRight
      const drawHeight = height - gridTop - gridBottom

      // Y轴最大值自适应(无小数点)
      let allValues = []
      this.data.forEach(series => {
        allValues = allValues.concat(series.data)
      })
      if (allValues.length === 0) return;

      // 动态计算Y轴极值(保留10%顶部余量,转为整数)
      const rawMaxVal = Math.max(...allValues)
      const rawMinVal = Math.min(...allValues)
      const maxVal = yAxis.max || Math.ceil(rawMaxVal * 1.1) // 向上取整,保证能容纳最大值
      const minVal = yAxis.min || (rawMinVal < 0 ? Math.floor(rawMinVal * 1.1) : 0) // 向下取整(负数),默认0
      const valRange = maxVal - minVal || 1

      // 计算X轴每个点的宽度
      const xStep = drawWidth / (this.categories.length - 1 || 1)

      // X轴标签防拥挤
      const minLabelWidth = 30
      const maxShowLabels = Math.floor(drawWidth / minLabelWidth)
      const labelInterval = maxShowLabels < this.categories.length
          ? Math.ceil(this.categories.length / maxShowLabels)
          : 1

      // 绘制网格线 + Y轴整数刻度
      ctx.setStrokeStyle(yAxis.splitLine?.lineStyle?.color || '#f5f5f7')
      ctx.setLineWidth(1)
      const yTickCount = 5
      const yTickStep = valRange / yTickCount

      for (let i = 0; i <= yTickCount; i++) {
        const y = gridTop + drawHeight - (i * drawHeight / yTickCount)
        // 绘制网格线
        ctx.beginPath()
        ctx.moveTo(gridLeft, y)
        ctx.lineTo(width - gridRight, y)
        ctx.stroke()

        // ========== 核心修改:Y轴显示整数(无小数点) ==========
        ctx.setFillStyle(yAxis.axisLabel?.color || '#666')
        ctx.setFontSize(yAxis.axisLabel?.fontSize || 12)
        const val = minVal + (i * yTickStep)
        // 转为整数(四舍五入),彻底去掉小数点
        const intVal = Math.round(val)
        let valText = intVal.toString()
        // 长数字处理(仍为整数格式)
        if (valText.length > 6) {
          valText = intVal.toLocaleString() // 用千分位显示长整数,如 1234567 → 1,234,567
        }
        // 文字右对齐,避免折叠
        const textWidth = ctx.measureText(valText).width
        ctx.fillText(valText, gridLeft - 10 - textWidth, y + 5)
      }

      // 绘制X轴标签
      ctx.setFillStyle(xAxis.axisLabel?.color || '#666')
      ctx.setFontSize(xAxis.axisLabel?.fontSize || 12)
      this.categories.forEach((text, index) => {
        if (index % labelInterval === 0) {
          const x = gridLeft + index * xStep
          let labelText = text
          if (text.length > 6) {
            labelText = text.slice(0, 6) + '...'
          }
          const textLines = labelText.split('\n')

          textLines.forEach((line, lineIdx) => {
            const textWidth = ctx.measureText(line).width
            ctx.fillText(line, x - textWidth / 2, height - gridBottom + 20 + (lineIdx * 12))
          })
        }
      })

      // 优化图例间距
      if (legend.show) {
        ctx.setFontSize(legend.textStyle?.fontSize || 12)
        let legendX = gridLeft + 10
        this.data.forEach((series, idx) => {
          let seriesName = series.name || `系列${idx + 1}`
          if (seriesName.length > 8) {
            seriesName = seriesName.slice(0, 8) + '...'
          }
          const textWidth = ctx.measureText(seriesName).width
          const legendItemWidth = textWidth + 25

          const legendY = gridTop - 30
          ctx.setFillStyle(series.color || color[idx % color.length])
          ctx.fillRect(legendX, legendY, 10, 10)
          ctx.setFillStyle('#666')
          ctx.fillText(seriesName, legendX + 15, legendY + 8)

          legendX += Math.max(80, legendItemWidth + 10)
        })
      }

      // 绘制平滑折线(无数据点)
      this.data.forEach((series, seriesIdx) => {
        const seriesColor = series.color || color[seriesIdx % color.length]

        ctx.setStrokeStyle(seriesColor)
        ctx.setLineWidth(2)
        ctx.beginPath()

        series.data.forEach((value, index) => {
          const x = gridLeft + index * xStep
          const y = gridTop + drawHeight - ((value - minVal) / valRange) * drawHeight

          if (index === 0) {
            ctx.moveTo(x, y)
          } else {
            if (lineSmooth && index < series.data.length - 1) {
              const prevX = gridLeft + (index - 1) * xStep
              const prevY = gridTop + drawHeight - ((series.data[index - 1] - minVal) / valRange) * drawHeight
              const nextX = gridLeft + (index + 1) * xStep
              const nextY = gridTop + drawHeight - ((series.data[index + 1] - minVal) / valRange) * drawHeight

              const cp1x = (prevX + x) / 2
              const cp1y = prevY
              const cp2x = (x + nextX) / 2
              const cp2y = y

              ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)
            } else {
              ctx.lineTo(x, y)
            }
          }
        })

        ctx.stroke()
      })

      ctx.draw()
    },
    // K线图绘制(同步修改Y轴为整数)
    drawKline () {
      const ctx = uni.createCanvasContext('uni-charts', this)
      this.ctx = ctx
      const { width, height } = this
      const { grid = {}, xAxis = {}, yAxis = {}, color = ['#B34C17', '#25AF69'] } = this.option

      ctx.clearRect(0, 0, width, height)

      // 调整Grid布局
      const gridTop = grid.top ? (typeof grid.top === 'string' ? parseFloat(grid.top) / 100 * height : grid.top) : 50
      const gridLeft = grid.left ? (typeof grid.left === 'string' ? parseFloat(grid.left) / 100 * width : grid.left) : 70
      const gridRight = grid.right ? (typeof grid.right === 'string' ? parseFloat(grid.right) / 100 * width : grid.right) : 20
      const gridBottom = grid.bottom ? (typeof grid.bottom === 'string' ? parseFloat(grid.bottom) / 100 * height : grid.bottom) : 40

      const drawWidth = width - gridLeft - gridRight
      const drawHeight = height - gridTop - gridBottom

      // Y轴自适应极值(整数)
      let allValues = []
      this.data.forEach(item => {
        allValues = allValues.concat([item.open, item.high, item.low, item.close])
      })
      if (allValues.length === 0) return;

      const rawMaxVal = Math.max(...allValues)
      const rawMinVal = Math.min(...allValues)
      const maxVal = yAxis.max || Math.ceil(rawMaxVal * 1.1)
      const minVal = yAxis.min || (rawMinVal < 0 ? Math.floor(rawMinVal * 1.1) : 0)
      const valRange = maxVal - minVal || 1

      const xStep = drawWidth / (this.data.length || 1)

      // 绘制网格线和Y轴整数刻度
      ctx.setStrokeStyle('#f5f5f7')
      ctx.setLineWidth(1)
      const yTickCount = 5
      for (let i = 0; i <= yTickCount; i++) {
        const y = gridTop + drawHeight - (i * drawHeight / yTickCount)
        ctx.beginPath()
        ctx.moveTo(gridLeft, y)
        ctx.lineTo(width - gridRight, y)
        ctx.stroke()

        // Y轴显示整数
        ctx.setFillStyle(yAxis.axisLabel?.color || '#666')
        ctx.setFontSize(yAxis.axisLabel?.fontSize || 12)
        const val = minVal + (i * valRange / yTickCount)
        const intVal = Math.round(val)
        let valText = intVal.toString()
        if (valText.length > 6) valText = intVal.toLocaleString()
        const textWidth = ctx.measureText(valText).width
        ctx.fillText(valText, gridLeft - 10 - textWidth, y + 5)
      }

      // X轴标签(防拥挤)
      ctx.setFillStyle(xAxis.axisLabel?.color || '#666')
      ctx.setFontSize(xAxis.axisLabel?.fontSize || 12)
      const minLabelWidth = 30
      const maxShowLabels = Math.floor(drawWidth / minLabelWidth)
      const labelInterval = maxShowLabels < this.categories.length
          ? Math.ceil(this.categories.length / maxShowLabels)
          : 1

      this.categories.forEach((text, index) => {
        if (index % labelInterval === 0) {
          const x = gridLeft + index * xStep + xStep / 2
          let labelText = text
          if (text.length > 6) labelText = text.slice(0, 6) + '...'
          ctx.fillText(labelText, x - ctx.measureText(labelText).width / 2, height - gridBottom + 20)
        }
      })

      // 绘制K线
      this.data.forEach((item, index) => {
        const { open, high, low, close } = item
        const x = gridLeft + index * xStep + xStep / 2
        const yOpen = gridTop + drawHeight - ((open - minVal) / valRange) * drawHeight
        const yClose = gridTop + drawHeight - ((close - minVal) / valRange) * drawHeight
        const yHigh = gridTop + drawHeight - ((high - minVal) / valRange) * drawHeight
        const yLow = gridTop + drawHeight - ((low - minVal) / valRange) * drawHeight

        // 绘制高低线
        ctx.setStrokeStyle(close >= open ? color[0] : color[1])
        ctx.setLineWidth(1)
        ctx.beginPath()
        ctx.moveTo(x, yHigh)
        ctx.lineTo(x, yLow)
        ctx.stroke()

        // 绘制实体
        const rectWidth = xStep / 3
        ctx.setFillStyle(close >= open ? color[0] : color[1])
        const rectY = Math.min(yOpen, yClose)
        const rectHeight = Math.abs(yClose - yOpen) || 2
        ctx.fillRect(x - rectWidth / 2, rectY, rectWidth, rectHeight)
      })

      ctx.draw()
    },
    touchStart (e) {
      this.touchInfo = {
        x: e.changedTouches[0].x,
        y: e.changedTouches[0].y,
        time: Date.now()
      }
    },
    touchMove (e) {
      this.touchInfo.x = e.changedTouches[0].x
      this.touchInfo.y = e.changedTouches[0].y
    },
    touchEnd (e) {
      // 可扩展点击交互逻辑
    }
  }
}
</script>

<style scoped>
.uni-charts {
  position: relative;
}
.charts-canvas {
  display: block;
}
.map-container {
  position: relative;
}
</style>