源码
<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>