一、小程序中实现,面积图的绘制,使用canvas进行绘制渲染(从左到右的渲染动画)
二、面积图封装组件【完整代码】 Component( { properties:{ title:{ type: String, value:'' } , chartData:{ type: Object, value:{ xAxis:[ ] , yAxis:[ ] , values:[ ] } } } , data:{ canvas: null, ctx: null, canvasWidth:0 , canvasHeight:0 , dpr:1 , // 所有尺寸单位都是rpx,改这里就生效 styles:{ yEmojiSize:42 , // Y轴表情大小 xTextSize:32 , // X轴文字大小 lineWidth:4 , // 折线粗细 pointRadius:8 // 圆点大小} , // 调整边距解决重叠和截断问题 padding:{ top:15 , right:40 , // 加大右边距,解决"第4周" 被截断 bottom:95 , // 距离底部的间距 left:120 // 图表-距离左边的间距,} , points:[ ] , animationProgress:0 , showBubble: false, bubbleData:{ } , bubbleLeft:0 , bubbleTop:0 , bubbleArrowDir:'' } , _animationTimer: null, _initTimer: null, _isDestroyed: false, _rpxToPx:1 , lifetimes:{ ready ( ) { this._isDestroyed= false const systemInfo= wx.getSystemInfoSync( ) this._rpxToPx= systemInfo.windowWidth /750 setTimeout(( ) = > { this._safeInitChart( ) }, 100 ) }, detached( ) { this._isDestroyed= true if( this._animationTimer) { clearTimeout( this._animationTimer) this._animationTimer= null } if( this._initTimer) { clearTimeout( this._initTimer) this._initTimer= null } if( this.data.ctx) { this.data.ctx.clearRect( 0 , 0 , this.data.canvasWidth, this.data.canvasHeight) } this.setData( { canvas: null, ctx: null, points: [], showBubble: false }) } }, / / 修复:监听所有配置项的变化,自动刷新图表 observers: { 'chartData, styles, padding': function( ) { if( this._initTimer) { clearTimeout( this._initTimer) } this._initTimer= setTimeout(( ) = > { this._safeInitChart( ) }, 50 ) } }, methods: { async _safeInitChart( retryCount= 0 ) { if( this._isDestroyed) return if( this._animationTimer) { clearTimeout( this._animationTimer) this._animationTimer= null } try { const query= wx.createSelectorQuery( ) .in( this) const res= await new Promise(( resolve) = > { query.select( '#emotionChart') .fields( { node: true, size: true }) .exec( resolve) }) if( ! res|| ! res[0 ]|| ! res[0 ].node) { if( retryCount< 3 ) { setTimeout(( ) = > { this._safeInitChart( retryCount+ 1 ) }, 100 ) } return } const canvas= res[0 ].node const ctx= canvas.getContext( '2 d') const dpr= wx.getSystemInfoSync( ) .pixelRatio const canvasWidth= res[0 ].width const canvasHeight= res[0 ].height canvas.width= canvasWidth* dpr canvas.height= canvasHeight* dpr ctx.scale( dpr, dpr) this.setData( { canvas, ctx, canvasWidth, canvasHeight, dpr, animationProgress: 0 , showBubble: false }) this.calculatePoints( ) this.startAnimation( ) } catch( err) { console.error( 'emotion- chart: 初始化异常', err) if( retryCount< 3 ) { setTimeout(( ) = > { this._safeInitChart( retryCount+ 1 ) }, 100 ) } } }, calculatePoints( ) { const { chartData, padding, canvasWidth, canvasHeight }= this.data const { xAxis, yAxis, values }= chartData const paddingLeft= padding.left* this._rpxToPx const paddingRight= padding.right* this._rpxToPx const paddingTop= padding.top* this._rpxToPx const paddingBottom= padding.bottom* this._rpxToPx const chartWidth= canvasWidth- paddingLeft- paddingRight const chartHeight= canvasHeight- paddingTop- paddingBottom const xCount= xAxis.length const xStep= chartWidth/ ( xCount- 1 ) const yCount= yAxis.length const yStep= chartHeight/ ( yCount- 1 ) const points= values.map(( value, index) = > ( { x: paddingLeft+ index* xStep, y: paddingTop+ ( yCount- 1 - value) * yStep, xLabel: xAxis[index], yEmoji: yAxis[value].emoji, yLabel: yAxis[value].label })) this.setData( { points} ) } ,startAnimation ( ) { if ( this._isDestroyed) return const animate= ( ) = > { if ( this._isDestroyed) return let newProgress= this.data.animationProgress +1 /30 if ( newProgress> 1 ) newProgress= 1 this.setData( { animationProgress: newProgress} ) this.drawChart( ) if ( newProgress< 1 ) { this._animationTimer= setTimeout( animate,33 ) } else { this._animationTimer= null} } animate( ) } ,drawChart ( ) { if ( this._isDestroyed|| ! this.data.ctx) return const{ ctx, canvasWidth, canvasHeight, padding, points, animationProgress, styles} = this.data const paddingLeft= padding.left * this._rpxToPx const paddingRight= padding.right * this._rpxToPx const paddingTop= padding.top * this._rpxToPx const paddingBottom= padding.bottom * this._rpxToPx ctx.clearRect( 0 ,0 , canvasWidth, canvasHeight) const chartWidth= canvasWidth - paddingLeft - paddingRight const chartHeight= canvasHeight - paddingTop - paddingBottom this.drawGrid( ctx, paddingLeft, paddingTop, chartWidth, chartHeight) this.drawYAxis( ctx, paddingLeft, paddingTop, chartHeight) this.drawXAxis( ctx, paddingLeft, paddingTop, chartWidth, chartHeight) const currentPointCount= Math.ceil( points.length * animationProgress) if ( currentPointCount< 1 ) return const currentPoints= points.slice( 0 , currentPointCount) this.drawCurve( ctx, paddingLeft, paddingTop, chartWidth, chartHeight, currentPoints) this.drawPoints( ctx, currentPoints) } , drawGrid( ctx, paddingLeft, paddingTop, chartWidth, chartHeight) { const{ chartData} = this.data const yCount= chartData.yAxis.length const yStep= chartHeight /( yCount -1 ) ctx.strokeStyle= '#cbd5e1' ctx.lineWidth= 1 ctx.setLineDash( [ 6 ,6 ] ) for ( let i= 0 ; i< yCount; i++) { const y= paddingTop + i * yStep ctx.beginPath( ) ctx.moveTo( paddingLeft, y) ctx.lineTo( paddingLeft + chartWidth, y) ctx.stroke( ) } ctx.setLineDash( [ ] ) } , drawYAxis( ctx, paddingLeft, paddingTop, chartHeight) { const{ chartData, styles} = this.data const yAxis= chartData.yAxis const yCount= yAxis.length const yStep= chartHeight /( yCount -1 ) ctx.font= ` ${ styles.yEmojiSize * this._rpxToPx} px sans-serif` ctx.textAlign= 'center' ctx.textBaseline= 'middle' ctx.fillStyle= '#64748b' for ( let i= 0 ; i< yCount; i++) { const y= paddingTop + i * yStep // 表情位置微调,进一步加大和虚线的间距 ctx.fillText( yAxis[ yCount -1 - i] .emoji, paddingLeft *0.3 , y) } } , drawXAxis( ctx, paddingLeft, paddingTop, chartWidth, chartHeight) { const{ chartData, styles} = this.data const xAxis= chartData.xAxis const xCount= xAxis.length const xStep= chartWidth /( xCount -1 ) ctx.font= ` ${ styles.xTextSize * this._rpxToPx} px sans-serif` ctx.textAlign= 'center' ctx.textBaseline= 'top' ctx.fillStyle= '#64748b' for ( let i= 0 ; i< xCount; i++) { const x= paddingLeft + i * xStep ctx.fillText( xAxis[ i] , x, paddingTop + chartHeight +10 * this._rpxToPx) } } , drawCurve( ctx, paddingLeft, paddingTop, chartWidth, chartHeight, currentPoints) { const{ styles} = this.data const gradient= ctx.createLinearGradient( 0 , paddingTop,0 , paddingTop + chartHeight) gradient.addColorStop( 0 ,'rgba(59, 130, 246, 0.2)' ) gradient.addColorStop( 1 ,'rgba(59, 130, 246, 0.05)' ) ctx.beginPath( ) ctx.moveTo( currentPoints[ 0 ] .x, paddingTop + chartHeight) ctx.lineTo( currentPoints[ 0 ] .x, currentPoints[ 0 ] .y) for ( let i= 0 ; i< currentPoints.length -1 ; i++) { const x1= currentPoints[ i] .x const y1= currentPoints[ i] .y const x2= currentPoints[ i +1 ] .x const y2= currentPoints[ i +1 ] .y const cpX= ( x1 + x2) /2 ctx.bezierCurveTo( cpX, y1, cpX, y2, x2, y2) } ctx.lineTo( currentPoints[ currentPoints.length -1 ] .x, paddingTop + chartHeight) ctx.closePath( ) ctx.fillStyle= gradient ctx.fill( ) ctx.beginPath( ) ctx.moveTo( currentPoints[ 0 ] .x, currentPoints[ 0 ] .y) for ( let i= 0 ; i< currentPoints.length -1 ; i++) { const x1= currentPoints[ i] .x const y1= currentPoints[ i] .y const x2= currentPoints[ i +1 ] .x const y2= currentPoints[ i +1 ] .y const cpX= ( x1 + x2) /2 ctx.bezierCurveTo( cpX, y1, cpX, y2, x2, y2) } ctx.strokeStyle= '#3b82f6' ctx.lineWidth= styles.lineWidth * this._rpxToPx ctx.lineCap= 'round' ctx.lineJoin= 'round' ctx.stroke( ) } , drawPoints( ctx, currentPoints) { const{ styles} = this.data const pointRadius= styles.pointRadius * this._rpxToPx currentPoints.forEach( point= > { ctx.beginPath( ) ctx.arc( point.x, point.y, pointRadius,0 , Math.PI *2 ) ctx.fillStyle= '#3b82f6' ctx.fill( ) ctx.strokeStyle= '#fff' ctx.lineWidth= 2 * this._rpxToPx ctx.stroke( ) } ) } , handleCanvasTouch( e) { if ( this._isDestroyed|| ! this.data.points.length) return const{ points, canvasWidth, canvasHeight, padding} = this.data consttouch = e.touches[ 0 ] const query= wx.createSelectorQuery( ) .in( this) query.select( '#emotionChart' ) .boundingClientRect( rect= > { if ( ! rect|| this._isDestroyed) return const touchX= ( touch.clientX - rect.left) *( canvasWidth / rect.width) const touchY= ( touch.clientY - rect.top) *( canvasHeight / rect.height) let nearestPoint= nulllet minDistance= 30 * this._rpxToPx points.forEach( point= > { const distance= Math.sqrt( Math.pow( touchX - point.x,2 ) + Math.pow( touchY - point.y,2 ) ) if ( distance< minDistance) { minDistance= distance nearestPoint= point} } ) if ( nearestPoint) { this.showBubbleCard( nearestPoint, rect) } else { this.setData( { showBubble:false } ) } } ) .exec( ) } , showBubbleCard( point, rect) { if ( this._isDestroyed) return const systemInfo= wx.getSystemInfoSync( ) const pxToRpx= 750 / systemInfo.windowWidth const pointCenterX= ( point.x *( rect.width / this.data.canvasWidth) + rect.left) * pxToRpx const pointCenterY= ( point.y *( rect.height / this.data.canvasHeight) + rect.top) * pxToRpx const bubbleWidth= 200 const bubbleHeight= 120 const arrowHeight= 12 const arrowTipOffset= 0 let bubbleLeft= pointCenterX - bubbleWidth /2 let bubbleTop= pointCenterY - bubbleHeight - arrowHeight + arrowTipOffsetlet bubbleArrowDir= '' if ( bubbleLeft< 20 ) bubbleLeft= 20 if ( bubbleLeft + bubbleWidth> 730 ) bubbleLeft= 730 - bubbleWidthif ( bubbleTop< 20 ) { bubbleTop= pointCenterY + arrowHeight + arrowTipOffset bubbleArrowDir= 'top' } this.setData( { showBubble: true, bubbleData:{ x: point.xLabel, yEmoji: point.yEmoji, yLabel: point.yLabel} , bubbleLeft, bubbleTop, bubbleArrowDir} ) } ,refreshData ( ) { this._safeInitChart( ) } } } ) < viewclass = "emotion-chart-wrapper" > < ! -- 图表标题(可选,父组件不传则不显示) --> < viewclass = "chart-title" wx:if= "{{title}}" > { { title} } < /view> < ! -- Canvas绘图区域 --> < canvastype = "2d" id = "emotionChart" class = "chart-canvas" bindtouchstart = "handleCanvasTouch" /> < ! -- 点击数据点弹出的气泡详情(主流样式) --> < viewclass = "bubble-card" wx:if= "{{showBubble}}" style = "left: {{bubbleLeft}}rpx; top: {{bubbleTop}}rpx;" > < ! -- 气泡箭头(自动调整方向) --> < viewclass = "bubble-arrow {{bubbleArrowDir}}" > < /view> < ! -- 气泡内容 --> < viewclass = "bubble-content" > < viewclass = "bubble-x" > { { bubbleData.x} } < /view> < viewclass = "bubble-y" > < textclass = "bubble-emoji" > { { bubbleData.yEmoji} } < /text> < textclass = "bubble-label" > { { bubbleData.yLabel} } < /text> < /view> < /view> < /view> < /view> .emotion-chart-wrapper{ width:100 %; position: relative; background-color:#fff; } /* 图表标题 */ .chart-title{ font-size: 48rpx; font-weight: bold; color:#1e293b; margin-bottom: 40rpx; padding-left: 20rpx; } /* Canvas绘图区域 */ .chart-canvas{ width:100 %; height: 600rpx; } /* 气泡详情卡片(主流圆角阴影样式) */ .bubble-card{ position: absolute; z-index:999 ; background-color:#fff; border-radius: 16rpx; box-shadow:0 8rpx 24rpx rgba( 0 ,0 ,0 ,0.12 ) ; padding: 24rpx 32rpx; min-width: 160rpx; max-width: 300rpx; } /* 气泡箭头(自动调整方向) */ .bubble-arrow{ position: absolute; width:0 ; height:0 ; border-left: 12rpx solid transparent; border-right: 12rpx solid transparent; border-top: 12rpx solid#fff; left:50 %; transform: translateX( -50%) ; bottom: -12rpx; } /* 箭头在上方的情况(数据点在顶部时) */ .bubble-arrow.top{ border-top: none; border-bottom: 12rpx solid#fff; top: -12rpx; bottom: auto; } /* 气泡内容 */ .bubble-content{ text-align: center; } .bubble-x{ font-size: 28rpx; color:#64748b; margin-bottom: 12rpx; } .bubble-y{ display: flex; align-items: center; justify-content: center; gap: 12rpx; } .bubble-emoji{ font-size: 40rpx; } .bubble-label{ font-size: 32rpx; font-weight: bold; color:#1e293b; } 三、父组件中引入组件并传递数据 { "navigationStyle" : "custom" ,"usingComponents" : { "emotion-chart" : "/components/emotion-chart/emotion-chart" } } < viewclass = "container" > < ! -- 使用情绪趋势波动图组件 --> < emotion-chartid = "myEmotionChart" title = "情绪趋势波动图" chart-data= "{{chartData}}" /> < ! -- 刷新数据按钮(可选) --> < buttonbindtap = "refreshChartData" class = "refresh-btn mt-40" > 刷新数据< /button> < /view> Page( { data:{ // 图表数据(Y轴现在支持配置emoji和文字标签) chartData:{ xAxis:[ '第1周' ,'第2周' ,'第3周' ,'第4周' ] , yAxis:[ { emoji:'😣' , label:'低落' } ,{ emoji:'😐' , label:'平静' } ,{ emoji:'😊' , label:'开心' } ,{ emoji:'🤩' , label:'兴奋' } ,{ emoji:'🔥' , label:'狂喜' } ] , values:[ 1 ,2 ,4 ,4 ] } } , // 刷新数据按钮点击事件refreshChartData ( ) { // 生成随机数据(模拟刷新) const newValues= [ ] for ( let i= 0 ; i< 4 ; i++) { newValues.push( Math.floor( Math.random( ) *5 )) } // 更新数据 this.setData( { 'chartData.values' : newValues} ) // 也可以调用组件的refreshData方法(可选,因为observer会自动监听数据变化) // this.selectComponent( '#myEmotionChart' ) .refreshData( ) } } ) .container{ padding: 40rpx 20rpx; background-color:#f8fafc; min-height: 100vh; } .mt-40{ margin-top: 40rpx; } .refresh-btn{ background-color:#3b82f6; color:#fff; border-radius: 16rpx; font-size: 32rpx; padding: 20rpx 40rpx; }