口中有德,目中有人,行中有善,心中有爱。
vue3+d3js 时空柱
vue3+d3js 时空柱

vue3+d3js 时空柱

image.png

源码

<template>
  <div class="chart" @touchstart="touchstart">
    <svg id="chart"></svg>
    <img :src="geoUrl" id="geoUrl" />
    <van-overlay z-index="9999" :show="show" @click="show = false">
      <div class="wrapper">
        <div id="wrapper">
          <img id="printscreen" :src="printscreenUrl" />
          <div id="bottom">
            <div class="logo"></div>
            <div class="right">
              <span>长按二维码识别更多</span>
              <img class="code" src="https://xxxxxx/h5-front/image/rch-download.png">
            </div>
          </div>
        </div>
      </div>
    </van-overlay>
  </div>
</template>

<script setup>
import * as d3 from 'd3'
import html2canvas from 'html2canvas'
import { Toast } from 'vant'
import ellipseUrl from '@/assets/img/home-ellipse.png'
import homeTopEllipseUrl from '@/assets/img/home-top-ellipse.png'
import geoUrl from '@/assets/img/home-geo.gif'
import { apiRedChinaMediaResourceUpload } from '@/api'
const { proxy } = getCurrentInstance()
const show = ref(false)
const printscreenUrl = ref('')
const emit = defineEmits(['update:active', 'changeText', 'sectionEvent'])
const props = defineProps({
  active: Number,
  scrollDomId: String,
  pageScrollTop: Number,
  textKey: String,
  topH: Number,
  cylinderList: Array,
  group: {
    type: Array,
    default: () => []
  }
})
const circleWidth = 160 // 主体宽度
const pathRGB = ['50,93,232', '92,239,227', '240,255,70'] // 柱体渐变线条的颜色
const svgVo = reactive({
  poiX: '',
  poiY: '',
  pageH: document.body.offsetHeight,
  pageW: document.body.offsetWidth,
  leftPathPoi: {},
  rightPathPoi: {},
  width: circleWidth,
  vm: null,
  textGroupVm: null,
  year: 0,
  yScale: null, // y轴比例尺
  yAxis: null,
  circleH: 50,
  semicircleRange: 14, // 高亮半圆幅度
  circleRightX: circleWidth - 60, // 右边点起点位置
  circleLeftX: circleWidth / 2.4, // 左边点起点位置
  circleList: [] // 添加的圆数组
})
const topH = computed(() => {
  return props.topH
})

const limit = 25
const homeBottomPathUrlPx = computed(() => {
  return circleWidth + 14 + 'px'
})
const sectionEvent = ref({})
/* ----------------------------------------------事件函数--------------------------------------------------*/
const touchstart = () => {
  scrollLock.value = true
}
// 转blob
const dataURLtoBlob = (dataurl) => {
  const arr = dataurl.split(',')
  const mime = arr[0].match(/:(.*?);/)[1]
  const bstr = atob(arr[1])
  let n = bstr.length
  let u8arr = new Uint8Array(n)
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n)
  }
  return new Blob([u8arr], { type: mime })
}
// 转文件
const blobToFile = (theBlob, fileName) => {
  theBlob.lastModifiedDate = new Date() // 文件最后的修改日期
  theBlob.name = fileName // 文件名
  return new File([theBlob], fileName, {
    type: theBlob.type,
    lastModified: Date.now()
  })
}
// 保存图片
const saveImg = () => {
  Toast.loading({
    duration: 0,
    forbidClick: true,
    message: '分享图片生成中...',
    className: 'toast-save'
  })
  html2canvas(document.getElementById('wrapper'), {
    logging: false,
    allowTaint: true,
    scale: window.devicePixelRatio,
    scrollY: 0,
    scrollX: 0,
    useCORS: true,
    backgroundColor: '#ffffff'
  }).then(async (canvas) => {
    console.log(canvas)
    let imgUrl = canvas.toDataURL('image/png')
    const blob = dataURLtoBlob(imgUrl)
    const file = blobToFile(blob, '时空柱截图.png')
    const { resourceUrl } = await apiRedChinaMediaResourceUpload({
      file: new FileData(file)
    })
    Toast.clear()
    // 客户端分享功能
    rch.shareImage({
      url: resourceUrl,
      channel: ['wechat', 'wechat-timeline', 'qq']
    }).then(res => {
      if (res.status === 60001){
        show.value = false
      }
    })
  })
}
// 客户端截图
const toImg = async () => {
  const { base64Image } = await rch.snapshotScreen({
    startY: topH.value
  })
  printscreenUrl.value = `data:image/png;base64,${base64Image}`
  show.value = true
  proxy.$nextTick(() => {
    saveImg()
  })
}

// 选中区间
const changeSection = (newValue, oldValue) => {
  svgVo.vm.selectAll('.sectionGroup').attr('opacity', '0')
  d3.select(`#sectionGroup${newValue}`).attr('opacity', '1')
  toScrollTop(
    d3.select(`#sectionGroup${newValue}`).attr('poiY') - svgVo.pageH / 3,
    newValue < oldValue
  )
}

// 绑定背景
const createPattern = (selection, id, imgPath) => {
  selection
    .append('pattern')
    .attr('height', '1')
    .attr('width', '1')
    .attr('patternContentUnits', 'objectBoundingBox')
    .attr('id', id)
    .append('image')
    .attr('height', '1')
    .attr('width', '1')
    .attr('preserveAspectRatio', 'none')
    .attr('xlink:href', imgPath)
}
// 创建时刻的数据块
const createG = (info, index, isLast) => {
  const setY = () => {
    return svgVo.poiY - index * svgVo.circleH * 2 - svgVo.circleH - limit
  }
  const poiY = setY()
  if (!sectionEvent.value.hasOwnProperty(info.subjectId)) {
    sectionEvent.value[info.subjectId] = []
  }
  sectionEvent.value[info.subjectId].push({
    poiY,
    info
  })

  const _g = svgVo.vm
    .append('g')
    .attr('class', 'ellipse-g')
    .attr('id', 'ellipse-g-' + index)
    .attr('subjectId', info.subjectId)
    .attr('poiY', poiY)
    .attr('transform', () => 'translate(0,0)')
    .style('opacity', '0')

  svgVo.circleList.push(_g)

  createPattern(_g, `pattern-${index}`, ellipseUrl)

  _g.append('ellipse')
    .attr('width', svgVo.width)
    .attr('height', svgVo.circleH)
    .attr('cx', svgVo.width / 2)
    .attr('cy', svgVo.circleH / 2)
    .attr('rx', isLast ? svgVo.width / 2 + 24 : svgVo.width / 2)
    .attr('ry', 40)
    .attr('stroke', 'none')
    .attr('fill', isLast ? 'url(#ellipseImg)' : `url(#pattern-${index})`)
    .style('opacity', '.8')

  _g.append('text')
    .attr('x', -75)
    .attr('y', svgVo.circleH - 20)
    .attr('text-anchor', 'middle')
    .attr('fill', '#fff')
    .attr('font-size', 14)
    .text(info.startYear || info.yearInt)

  createText(_g, { x: svgVo.circleRightX, y: 30, data: { ...info, index } })
}
// 文案分段
const textBreaking = (text, rectX, rectY, year) => {
  console.log(text, rectY)
  const len = text.length
  const topText = text.substring(0, 8)
  const midText = text.substring(8, 16)
  let botText = text.substring(16, len)
  const topY = rectY + 10
  const midY = rectY + 10
  const botY = rectY + 10
  if (len > 20) {
    botText = text.substring(16, 22) + '...'
  }
  const arr = [
    {
      x: rectX,
      y: topY,
      text: topText
    }
  ]
  midText &&
    arr.push({
      x: rectX,
      y: midY,
      text: midText
    })
  botText &&
    arr.push({
      x: rectX,
      y: botY,
      text: botText
    })
  arr.slice(-1)[0].text += year
  return arr
}
// 创建线条
const createLine = ({ vm, x1, y1, x2, y2, pathW, color }) => {
  vm.append('line')
    .attr('x1', x1)
    .attr('y1', y1)
    .attr('x2', x2)
    .attr('y2', y2)
    .attr('stroke', color)
    .attr('stroke-width', pathW)
}

// 画线条
const _createPath = ({ vm, d, pathW, linearGradientId, filterId }) => {
  const _path = vm
    .append('path')
    .attr('d', d)
    .attr('stroke', `url(#${linearGradientId})`)
    .attr('fill', 'none')
    .attr('stroke-width', pathW)
  if (filterId) {
    _path.attr('filter', `url(#${filterId})`)
  }
}
// 创建高亮阴影
const createFilter = (id, dx) => {
  if (!id) {
    return
  }
  const filter = svgVo.vm.append('defs').append('filter').attr('id', id)
  filter
    .append('feOffset')
    .attr('result', 'offOut')
    .attr('in', 'SourceGraphic')
    .attr('dx', dx)
    .attr('dy', '0')
  filter
    .append('feColorMatrix')
    .attr('result', 'matrixOut')
    .attr('in', 'offOut')
    .attr('type', 'matrix')
    .attr('values', '0.5 0 0 0 0 0 0.2 0 0 0 0 0 0.2 0 0 0 0 0 1 0')
  filter
    .append('feGaussianBlur')
    .attr('result', 'blurOut')
    .attr('in', 'offOut')
    .attr('stdDeviation', '1.5')
  filter
    .append('feBlend')
    .attr('in', 'SourceGraphic')
    .attr('in2', 'blurOut')
    .attr('mode', 'normal')
}
// 渐变贝塞尔曲线
const createLinearGradient = (id, RGB) => {
  const linearGradient = svgVo.vm
    .append('defs')
    .append('linearGradient')
    .attr('id', id)
    .attr('x1', '0%')
    .attr('y1', '0%')
    .attr('x2', '0%')
    .attr('y2', '100%')
  linearGradient
    .append('stop')
    .attr('offset', '0%')
    .attr('stop-color', `rgba(${RGB},0)`)
  linearGradient
    .append('stop')
    .attr('offset', '2%')
    .attr('stop-color', `rgba(${RGB},1)`)
  linearGradient
    .append('stop')
    .attr('offset', '95%')
    .attr('stop-color', `rgba(${RGB},1)`)
  linearGradient
    .append('stop')
    .attr('offset', '98%')
    .attr('stop-color', `rgba(${RGB},0.5)`)
  linearGradient
    .append('stop')
    .attr('offset', '100%')
    .attr('stop-color', `rgba(${RGB},0.1)`)
}

// 曲线
const createPath = ({ vm, x1, y1, x2, y2, gap = 0, isLeft }) => {
  const colors = isLeft ? pathRGB : pathRGB.reverse()
  const inflexion = 40
  colors.map((color, index) => {
    const x = x1 - index * gap
    const filterId =
      !index || index === colors.length - 1
        ? 'filter-' + color + '-' + index
        : ''
    createFilter(filterId, !index ? '2' : '-2')
    _createPath({
      vm,
      d: isLeft
        ? `M ${x} ${y1} ${x} ${y2 - inflexion},${x2 - (index + 1) * 3} ${y2}`
        : `M ${x} ${y1} ${x} ${y2 - inflexion},${x2 - (index + 1) * 3} ${y2}`,
      color,
      linearGradientId: `linearGradient-${color}`,
      filterId: filterId,
      pathW: '1px'
    })
  })
}

// 区间分组
const createSectionGroup = (info, i = 0) => {
  const textGroupVm = svgVo.vm
    .append('g')
    .attr('id', 'sectionGroup' + i)
    .attr('index', i)
    .attr('class', 'sectionGroup')
    .attr('poiY', info.startPoiY)
    .attr('transform', `translate(0,${info.startPoiY})`)
    .attr('opacity', '0')
    .on('click', () => {
      emit('update:active', Number(textGroupVm.attr('index')))
      console.log(textGroupVm.attr('index'))
    })

  const startPoi = {
    x: svgVo.width / 2,
    y: svgVo.circleH
  }
  // num为绑定的区间 ,+ 1是为了精确定位到最后一个元素
  // const endPoi = {
  //   x: svgVo.width / 2,
  //   y: info.endPoiY
  // }
  const opacity = 0.1
  const height = info.endPoiY - info.startPoiY
  textGroupVm
    .append('rect')
    .attr('x', 0)
    .attr('y', startPoi.y / 2)
    .attr('rx', 2)
    .attr('fill', `rgba(89, 255, 247, ${opacity})`)
    .attr('width', svgVo.width)
    .attr('height', height)
  // 上半圆
  textGroupVm
    .append('path')
    .attr('x', 0)
    .attr(
      'd',
      `M0,${svgVo.circleH / 2} A50,${svgVo.semicircleRange} 0 0,1 ${
        svgVo.width
      },${svgVo.circleH / 2}z`
    )
    .attr('fill', `rgba(89, 255, 247, ${opacity}`)

  // 下半圆
  textGroupVm
    .append('path')
    .attr('x', 0)
    .attr(
      'd',
      `M0,${height + svgVo.circleH / 2} A50,${svgVo.semicircleRange} 0 0,0 ${
        svgVo.width
      },${height + svgVo.circleH / 2}z`
    )
    .attr('fill', `rgba(89, 255, 247, ${opacity}`)
}

const createPolyline = (vm, points) => {
  vm.append('polyline')
    .attr('points', points)
    .attr('fill', 'none')
    .attr('stroke', '#fff')
    .attr('stroke-width', '2px')
}

// 创建可点击文案
const createText = (vm, { x, y, data }) => {
  let rectW = 110 // 文案宽度
  const rectH = 18 // 一行文字基数
  const x2 = x + rectH * 2 // 线的终点 rect起点
  const padding = 5
  const isLeft = data.index % 2 === 0
  !isLeft &&
    createLine({
      vm,
      x1: isLeft ? svgVo.circleLeftX : x,
      y1: isLeft ? y : y,
      x2: isLeft ? svgVo.circleLeftX : x2,
      y2: isLeft ? y + 20 : y,
      pathW: '1px',
      color: '#fff'
    })
  isLeft &&
    createPolyline(
      vm,
      `${svgVo.circleLeftX},${y} ${svgVo.circleLeftX},${y + rectH * 2} ${
        svgVo.circleLeftX - 35
      },${y + rectH * 2}`
    )

  // 圆点
  vm.append('circle')
    .attr('cx', isLeft ? svgVo.circleLeftX : x - 2)
    .attr('cy', y)
    .attr('r', 3)
    .attr('stroke', '#fff')
    .attr('stroke-width', '2')
    .attr('fill', isLeft ? 'rgba(32, 226, 195, 1)' : 'rgba(223, 51, 0, 0.60)')
  let rectX = isLeft ? -60 : x2
  let rectY = isLeft ? 50 : y - rectH / 2
  const year = data.yearInt || ''
  const textArr =
    data[props.textKey].length <= 3
      ? [
          {
            x: 0,
            y: 13,
            text: data[props.textKey] + (year ? `(${year})` : '')
          }
        ]
      : textBreaking(
          data[props.textKey],
          rectX + padding,
          padding,
          year ? `(${year})` : ''
        )
  // 矩形
  let _H = textArr.length * rectH
  let diff = _H > 34 ? 25 : 10 // 最后一个的安全边距计算
  if (textArr.length === 1) {
    // 特殊处理一行的场景
    if (textArr[0].text.indexOf('(') > -1) {
      // 3 是追加的年份 (xxxx)6个字节 所占用了3个中文长度
      rectW = (textArr[0].text.length - 3) * 13.5 + padding
    } else {
      rectW = textArr[0].text.length * 13.5 + padding
    }

    _H = 22
    isLeft && (rectX = -textArr[0].text.length * 6.5)
    if (isLeft && textArr[0].text.length <= 2){
      rectX = 0
      rectY += 5
    }
    diff = 0
  }
  console.log(_H)
  const rect = vm
    .append('rect')
    .attr('x', rectX)
    .attr('y', rectY)
    .attr('rx', 5)
    // .attr('stroke', isLeft ? 'rgba(32, 226, 195, 1)' : 'rgba(223, 51, 0, 0.60)')
    .attr('stroke-width', 2)
    .attr('width', rectW)
    .attr('height', _H)
    .attr('fill', '#FFFFFF')

  // 文本内容
  const text = vm
    .append('text')
    .attr('x', Number(rect.attr('x')) + padding)
    .attr('y', rectY)
    .attr('fill', '#333333')
    .attr('font-size', 12)
    .attr('font-family', 'PingFangSC-Regular')
    .on('click', () => {
      emit('changeText', data)
    })
  textArr.map((item) => {
    text
      .append('tspan')
      .attr('x', text.attr('x'))
      .attr('dy', item.y)
      .text(item.text)
  })
}
// 初始化svg
const initsvgVo = (diff) => {
  const upH = diff * svgVo.circleH * 2
  const chartH = upH + 100
  svgVo.poiY = upH
  svgVo.vm = d3
    .select('#chart')
    .attr('height', chartH)
    .append('g')
    .attr('width', svgVo.width)
    .attr('height', upH)
    .attr('id', 'svgVo')
    .attr('green', '#fff000')
    .attr('fill', '#fff')
    .attr(
      'transform',
      () => `translate(${svgVo.pageW / 2 - svgVo.width / 2},0)`
    )
  pathRGB.map((rgb, index) => {
    createLinearGradient(`linearGradient-${rgb}`, rgb)
  })
}
const scrollLock = ref(false)
// 移动
const smoothMove = (start, end, speed, fun) => {
  const duration = speed * 1000 // 毫秒
  const [startX, startY] = start
  const [endX, endY] = end
  const distanceX = endX - startX
  const distanceY = endY - startY
  console.log('目标距离', distanceY)
  const startTime = performance.now()
  let timer = null
  const ease = (t) => {
    return t * (2 - t) // 使用简单的缓动函数
  }
  const step = (currentTime) => {
    const elapsedTime = currentTime - startTime
    const progress = Math.min(elapsedTime / duration, 1)
    const easeProgress = ease(progress) // 使用缓动函数使移动更平滑
    const positionX = startX + distanceX * easeProgress
    const positionY = startY + distanceY * easeProgress
    if (fun && typeof fun === 'function') {
      fun([positionX, positionY])
    }
    if (scrollLock.value === true) { // 锁禁止滑动
      cancelAnimationFrame(timer)
      scrollLock.value = false
      return
    }
    if (progress < 1) {
      timer = requestAnimationFrame(step)
    } else {
      cancelAnimationFrame(timer)
      return
    }
  }
  timer = requestAnimationFrame(step)
}
const toScrollTop = (scrollTop) => {
  if (!props.scrollDomId) {
    console.warn('滚动没有传入domId')
    return
  }
  const home = document.getElementById(props.scrollDomId)
  smoothMove([0, home.scrollTop], [0, scrollTop], 3, ([x, y]) => {
    home.scrollTop = y
  })
}

watch(
  () => props.group,
  (newValue, oldValue) => {
    proxy.$nextTick(() => {
      props.group.map((item, i) => {
        createSectionGroup(item, i)
      })
    })
  }
)

// 获取二维码
const getCodeImg = () => {
  const getBase64Image = img => {
    let canvas = document.createElement("canvas");
    canvas.width = img.width;
    canvas.height = img.height;
    let ctx = canvas.getContext("2d");
    ctx.drawImage(img, 0, 0, img.width, img.height);
    let ext = img.src.substring(img.src.lastIndexOf(".")+1).toLowerCase();
    let dataURL = canvas.toDataURL("image/"+ext);
    return dataURL;
  }
  let image = new Image();
  image.crossOrigin='Anonymous'
  image.src = 'https://xxxxxxx/red-china/h5-front/image/rch-download.png';
  image.onload = function(){
    let base64 = getBase64Image(image);
  }
}

const init = () => {
  const diff = props.cylinderList.length
  initsvgVo(diff)
  const x2 = parseInt(circleWidth / 3)
  svgVo.leftPathPoi = {
    x1: 0,
    y1: svgVo.poiY - (diff - 1) * svgVo.circleH * 2 - svgVo.circleH,
    x2: x2,
    y2: svgVo.poiY + 60
  }
  const gap = 3
  svgVo.rightPathPoi = {
    x1: svgVo.width + 6,
    y1: svgVo.poiY - (diff - 1) * svgVo.circleH * 2 - svgVo.circleH,
    x2: x2 * 2 + 10,
    y2: svgVo.poiY + 60
  }
  // 左边线条
  createPath({
    vm: svgVo.vm,
    gap,
    isLeft: true,
    ...svgVo.leftPathPoi
  })

  // 右边线条
  createPath({
    vm: svgVo.vm,
    gap,
    ...svgVo.rightPathPoi
  })

  createPattern(svgVo.vm, 'ellipseImg', homeTopEllipseUrl)

  props.cylinderList.map((item, i) => {
    createG(item, i, i === props.cylinderList.length - 1)
  })
  emit('sectionEvent', sectionEvent.value)

  proxy.$nextTick(() => {
    // emit('update:active', 0)
    setTimeout(() => {
      // 执行动画过渡效果
      svgVo.circleList.map((selection) => {
        const poiY = selection.attr('poiY')
        selection
          .attr('transform', () => `translate(0,${poiY})`)
          .style('transition', '3ms')
          .style('opacity', '1')
      })
    }, 500)
  })
}
defineExpose({
  changeSection,
  toScrollTop,
  toImg,
  scrollLock
})
/* ----------------------------------------------生命周期--------------------------------------------------*/
onMounted(init)
</script>

<style scoped lang="scss">
.chart {
  .wrapper {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    > div {
      width: 80%;
      height: 80%;
      padding: 18px;
      background: white;
      position: relative;
      border-radius: 8px;
      display: flex;
      flex-direction: column;
      img {
        width: 100%;
        flex: 1;
      }
      #bottom {
        display: flex;
        justify-content: space-between;
        align-items: center;
        height: 67px;
        .logo {
          width: 50px;
          height: 50px;
          border-radius: 50%;
          background: url('~/img/logo.png');
          background-size: 100% 100%;
        }
        .right {
          font-size: 13px;
          font-family: PingFangSC-Regular, PingFang SC;
          font-weight: 400;
          color: #666666;
          line-height: 18px;
          display: flex;
          align-items: center;
          span {
            display: inline-block;
            width: 70px;
            margin-right: 20px;
          }
          .code {
            display: block;
            width: 52px;
            height: 52px;
            pointer-events: none;
          }
        }
      }
    }
  }
  > img {
    user-select: none;
    pointer-events: none;
    display: block;
    margin: auto;
  }

  #homeBottomPathUrl {
    width: v-bind(homeBottomPathUrlPx);
    object-fit: cover;
  }
  #geoUrl {
    width: v-bind(homeBottomPathUrlPx);
  }
  #chart {
    width: 100%;
    display: block;
  }
}
</style>

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注