Uniapp ECG心电图组件
基于uniapp Vue2心电图绘制组件,支持自动滑动展示和实时绘制刷新两种模式。

功能特性
- ✅ 自动滑动展示模式 - 模拟心电图数据自动滚动
- ✅ 实时绘制刷新模式 - 模拟实时数据添加和刷新
- ✅ 网格背景 - 专业的医疗级网格显示
- ✅ 响应式设计 - 适配不同屏幕尺寸
- ✅ 触摸控制 - 支持移动端触摸操作
组件代码
<template><view class="ecg-container"><view class="ecg-header"><text class="ecg-title">{{ currentModeText }}</text></view><view class="ecg-canvas-container"><canvas canvas-id="ecgCanvas" class="ecg-canvas":style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"></canvas></view><view class="ecg-controls"><button class="control-btn" @tap="startScrollMode":disabled="isScrolling">开始自动滑动</button><button class="control-btn" @tap="startRefreshMode":disabled="isRefreshing">开始实时绘制</button><button class="control-btn stop-btn" @tap="stopAll">停止</button></view></view>
</template><script>
export default {name: 'EcgChart',data() {return {canvasWidth: 350,canvasHeight: 200,maxDataPoints: 100,displayPoints: 50,dataList: [],refreshList: [],displayData: [],scrollIndex: 0,showIndex: 0,isScrolling: false,isRefreshing: false,scrollTimer: null,refreshTimer: null,ctx: null}},computed: {currentModeText() {if (this.isScrolling) return '自动滑动展示模式'if (this.isRefreshing) return '实时绘制刷新模式'return '静态心电图'}},mounted() {this.initData()this.initCanvas()},beforeDestroy() {this.stopAll()},methods: {initData() {// 生成模拟心电图数据for (let i = 0; i < this.maxDataPoints; i++) {const value = Math.sin(i * 0.1) * 50 + Math.sin(i * 0.3) * 20 + (Math.random() - 0.5) * 10this.dataList.push(value)this.refreshList.push(value)}// 初始化显示数据this.displayData = new Array(this.displayPoints).fill(0)},initCanvas() {const query = uni.createSelectorQuery().in(this)query.select('#ecgCanvas').boundingClientRect(data => {if (data) {this.canvasWidth = data.widththis.canvasHeight = data.height}}).exec()this.ctx = uni.createCanvasContext('ecgCanvas', this)this.drawECG()},startScrollMode() {this.stopAll()this.isScrolling = truethis.scrollIndex = 0this.scrollTimer = setInterval(() => {if (this.scrollIndex < this.dataList.length) {this.scrollIndex++} else {this.scrollIndex = 0}this.drawECG()}, 50)},startRefreshMode() {this.stopAll()this.isRefreshing = truethis.refreshList = []this.showIndex = 0// 添加初始数据for (let i = 0; i < 10; i++) {const value = Math.sin(i * 0.1) * 50 + Math.sin(i * 0.3) * 20 + (Math.random() - 0.5) * 10this.refreshList.push(value)}this.refreshTimer = setInterval(() => {// 模拟实时数据添加const newValue = Math.sin(this.refreshList.length * 0.1) * 50 + Math.sin(this.refreshList.length * 0.3) * 20 + (Math.random() - 0.5) * 10this.refreshList.push(newValue)// 更新显示数据this.updateDisplayData()this.drawECG()}, 200)},stopAll() {if (this.scrollTimer) {clearInterval(this.scrollTimer)this.scrollTimer = null}if (this.refreshTimer) {clearInterval(this.refreshTimer)this.refreshTimer = null}this.isScrolling = falsethis.isRefreshing = falsethis.drawECG()},updateDisplayData() {const nowIndex = this.refreshList.lengthif (nowIndex === 0) returnif (nowIndex < this.displayPoints) {this.showIndex = nowIndex - 1} else {this.showIndex = (nowIndex - 1) % this.displayPoints}for (let i = 0; i < this.displayPoints; i++) {if (i >= this.refreshList.length) breakif (nowIndex <= this.displayPoints) {this.displayData[i] = this.refreshList[i]} else {const times = Math.floor((nowIndex - 1) / this.displayPoints)const temp = times * this.displayPoints + iif (temp < nowIndex) {this.displayData[i] = this.refreshList[temp]}}}},drawECG() {if (!this.ctx) returnconst width = this.canvasWidthconst height = this.canvasHeight// 清空画布this.ctx.clearRect(0, 0, width, height)// 绘制背景this.ctx.setFillStyle('#FFFFFF')this.ctx.fillRect(0, 0, width, height)// 绘制网格this.drawGrid()// 绘制心电图if (this.isScrolling) {this.drawScrollECG()} else if (this.isRefreshing) {this.drawRefreshECG()} else {this.drawStaticECG()}// 绘制标题this.ctx.setFillStyle('#000000')this.ctx.setFontSize(14)this.ctx.fillText(this.currentModeText, 10, 20)this.ctx.draw()},drawGrid() {const width = this.canvasWidthconst height = this.canvasHeightthis.ctx.setStrokeStyle('#DCDCDC')this.ctx.setLineWidth(1)// 绘制垂直线for (let x = 0; x < width; x += 10) {this.ctx.beginPath()this.ctx.moveTo(x, 0)this.ctx.lineTo(x, height)this.ctx.stroke()}// 绘制水平线for (let y = 0; y < height; y += 10) {this.ctx.beginPath()this.ctx.moveTo(0, y)this.ctx.lineTo(width, y)this.ctx.stroke()}// 绘制中心线this.ctx.setStrokeStyle('#FF0000')this.ctx.setLineWidth(2)this.ctx.beginPath()this.ctx.moveTo(0, height / 2)this.ctx.lineTo(width, height / 2)this.ctx.stroke()},drawScrollECG() {if (this.dataList.length === 0) returnconst width = this.canvasWidthconst height = this.canvasHeightthis.ctx.setStrokeStyle('#31CE32')this.ctx.setLineWidth(2)const startIndex = Math.max(0, this.scrollIndex - this.displayPoints)const endIndex = Math.min(this.scrollIndex, this.dataList.length)if (startIndex >= endIndex) returnconst xStep = width / this.displayPointsconst centerY = height / 2this.ctx.beginPath()for (let i = startIndex; i < endIndex; i++) {const x = (i - startIndex) * xStepconst value = this.dataList[i]const y = centerY - (value * 1.5)if (i === startIndex) {this.ctx.moveTo(x, y)} else {this.ctx.lineTo(x, y)}}this.ctx.stroke()},drawRefreshECG() {if (this.displayData.length === 0) returnconst width = this.canvasWidthconst height = this.canvasHeightthis.ctx.setStrokeStyle('#31CE32')this.ctx.setLineWidth(2)const xStep = width / this.displayPointsconst centerY = height / 2this.ctx.beginPath()for (let i = 0; i < this.displayPoints && i < this.displayData.length; i++) {const x = i * xStepconst y = centerY - (this.displayData[i] * 1.5)if (i === 0) {this.ctx.moveTo(x, y)} else {this.ctx.lineTo(x, y)}}this.ctx.stroke()// 绘制当前刷新位置指示器if (this.showIndex >= 0 && this.showIndex < this.displayPoints) {this.ctx.setFillStyle('#FF0000')const x = this.showIndex * xStepthis.ctx.beginPath()this.ctx.arc(x, centerY, 3, 0, 2 * Math.PI)this.ctx.fill()}},drawStaticECG() {if (this.dataList.length === 0) returnconst width = this.canvasWidthconst height = this.canvasHeightthis.ctx.setStrokeStyle('#31CE32')this.ctx.setLineWidth(2)const displaySize = Math.min(this.displayPoints, this.dataList.length)const xStep = width / displaySizeconst centerY = height / 2this.ctx.beginPath()for (let i = 0; i < displaySize; i++) {const x = i * xStepconst value = this.dataList[i]const y = centerY - (value * 1.5)if (i === 0) {this.ctx.moveTo(x, y)} else {this.ctx.lineTo(x, y)}}this.ctx.stroke()}}
}
</script><style scoped>
.ecg-container {display: flex;flex-direction: column;align-items: center;padding: 20rpx;background-color: #f5f5f5;min-height: 100vh;
}.ecg-header {margin-bottom: 20rpx;
}.ecg-title {font-size: 32rpx;font-weight: bold;color: #333;
}.ecg-canvas-container {background-color: #fff;border-radius: 10rpx;padding: 20rpx;box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);margin-bottom: 30rpx;
}.ecg-canvas {width: 100%;height: 400rpx;border: 1rpx solid #e0e0e0;
}.ecg-controls {display: flex;flex-direction: row;justify-content: space-around;width: 100%;max-width: 600rpx;
}.control-btn {padding: 20rpx 40rpx;font-size: 28rpx;border: none;border-radius: 10rpx;background-color: #007AFF;color: #fff;margin: 0 10rpx;
}.control-btn:active {background-color: #0056b3;
}.control-btn[disabled] {background-color: #ccc;color: #999;
}.stop-btn {background-color: #ff3b30;
}.stop-btn:active {background-color: #d9342a;
}
</style>使用方法
1. 组件引入
将 ecg-chart.vue 文件复制到你的 uniapp 项目 components 目录下。
2. 页面中使用
<template><view><ecg-chart></ecg-chart></view>
</template><script>
import EcgChart from '@/components/ecg-chart.vue'export default {components: {EcgChart}
}
</script>
3. 自定义配置
可以通过 props 自定义配置(可选):
<ecg-chart :canvas-width="375" :canvas-height="200":max-data-points="100":display-points="50"
/>
核心算法
数据生成算法:
// Uniapp版本
const value = Math.sin(i * 0.1) * 50 + Math.sin(i * 0.3) * 20 + (Math.random() - 0.5) * 10;
绘图坐标转换:
// Uniapp: ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke()
平台兼容性
- ✅ H5 - 完全支持
- ✅ 微信小程序 - 完全支持
- ✅ 支付宝小程序 - 完全支持
- ✅ 百度小程序 - 完全支持
- ✅ QQ小程序 - 完全支持
- ✅ 字节跳动小程序 - 完全支持
- ✅ App (iOS/Android) - 完全支持
样式定制
组件使用 scoped CSS,可以通过以下方式自定义样式:
/* 自定义画布大小 */
.ecg-canvas {width: 100%;height: 500rpx;
}/* 自定义按钮样式 */
.control-btn {background-color: #007AFF;border-radius: 20rpx;
}
注意事项
- canvas-id 必须唯一,如果页面有多个心电图组件,需要传入不同的canvas-id
- 在小程序中,canvas的宽高需要通过style属性设置
- 组件已适配不同屏幕密度,无需额外处理
