602 lines
20 KiB
Kotlin
602 lines
20 KiB
Kotlin
package com.example.cmake_project_test
|
||
|
||
import android.content.Context
|
||
import android.graphics.*
|
||
import android.util.AttributeSet
|
||
import android.util.Log
|
||
import android.view.MotionEvent
|
||
import android.view.View
|
||
import android.view.ScaleGestureDetector
|
||
import kotlin.math.max
|
||
import kotlin.math.min
|
||
|
||
/**
|
||
* 可调节的通用生理信号图表视图
|
||
* 支持多种设备类型和通道数量的动态显示,具有缩放、平移、时间窗口调节等功能
|
||
*/
|
||
class DynamicChartView @JvmOverloads constructor(
|
||
context: Context,
|
||
attrs: AttributeSet? = null,
|
||
defStyleAttr: Int = 0
|
||
) : View(context, attrs, defStyleAttr) {
|
||
|
||
private val paint = Paint().apply {
|
||
color = Color.BLUE
|
||
strokeWidth = 2f
|
||
style = Paint.Style.STROKE
|
||
isAntiAlias = false // 关闭抗锯齿,提高绘制性能
|
||
}
|
||
|
||
private val gridPaint = Paint().apply {
|
||
color = Color.LTGRAY
|
||
strokeWidth = 1f
|
||
style = Paint.Style.STROKE
|
||
alpha = 80
|
||
}
|
||
|
||
private val textPaint = Paint().apply {
|
||
color = Color.BLACK
|
||
textSize = 20f
|
||
isAntiAlias = true
|
||
}
|
||
|
||
private val controlPaint = Paint().apply {
|
||
color = Color.RED
|
||
strokeWidth = 3f
|
||
style = Paint.Style.STROKE
|
||
isAntiAlias = true
|
||
}
|
||
|
||
private val path = Path()
|
||
private var dataPoints = mutableListOf<Float>()
|
||
private var maxDataPoints = 1000 // 默认数据点数量
|
||
private var minValue = Float.MAX_VALUE
|
||
private var maxValue = Float.MIN_VALUE
|
||
private var isDataAvailable = false
|
||
|
||
// 图表配置
|
||
private var chartTitle = "通道"
|
||
private var channelIndex = 0
|
||
private var deviceType = "未知设备"
|
||
private var sampleRate = 250f // 默认采样率
|
||
private var timeWindow = 4f // 默认时间窗口(秒)
|
||
|
||
// 性能优化:真正的逐点绘制
|
||
private val dataBuffer = mutableListOf<Float>()
|
||
|
||
// 多通道数据支持(用于PPG等设备)
|
||
private val multiChannelDataPoints = mutableListOf<MutableList<Float>>()
|
||
private val multiChannelDataBuffers = mutableListOf<MutableList<Float>>()
|
||
private var isMultiChannelMode = false
|
||
private val channelColors = listOf(Color.RED, Color.BLUE) // 通道颜色
|
||
private var lastUpdateTime = 0L
|
||
private val updateInterval = 2L // 2ms更新间隔,实现500Hz显示
|
||
private var lastDrawTime = 0L
|
||
private val drawInterval = 1L // 1ms绘制间隔,实现1000Hz绘制频率
|
||
|
||
// 交互控制参数
|
||
private var scaleFactor = 1.0f
|
||
private var translateX = 0f
|
||
private var translateY = 0f
|
||
private var viewportStart = 0f
|
||
private var viewportEnd = 1.0f
|
||
private var isDragging = false
|
||
private var lastTouchX = 0f
|
||
private var lastTouchY = 0f
|
||
|
||
// 手势检测器
|
||
private val scaleGestureDetector = ScaleGestureDetector(context, ScaleListener())
|
||
|
||
// 控制按钮区域
|
||
private val controlButtonRect = RectF()
|
||
private val fullscreenButtonRect = RectF()
|
||
|
||
// 全屏模式相关
|
||
private var isFullscreenMode = false
|
||
private var originalLayoutParams: android.view.ViewGroup.LayoutParams? = null
|
||
|
||
/**
|
||
* 设置图表配置
|
||
*/
|
||
fun setChartConfig(
|
||
title: String,
|
||
channelIdx: Int,
|
||
device: String,
|
||
maxPoints: Int,
|
||
sampleRateHz: Float,
|
||
timeWindowSec: Float
|
||
) {
|
||
chartTitle = title
|
||
channelIndex = channelIdx
|
||
deviceType = device
|
||
maxDataPoints = maxPoints
|
||
sampleRate = sampleRateHz
|
||
timeWindow = timeWindowSec
|
||
resetViewport()
|
||
invalidate()
|
||
}
|
||
|
||
/**
|
||
* 设置多通道模式(用于PPG等设备)
|
||
*/
|
||
fun setMultiChannelMode(channelCount: Int) {
|
||
isMultiChannelMode = true
|
||
multiChannelDataPoints.clear()
|
||
multiChannelDataBuffers.clear()
|
||
|
||
// 初始化每个通道的数据结构
|
||
for (i in 0 until channelCount) {
|
||
multiChannelDataPoints.add(mutableListOf())
|
||
multiChannelDataBuffers.add(mutableListOf())
|
||
}
|
||
}
|
||
|
||
fun updateData(newData: List<Float>) {
|
||
if (newData.isEmpty()) return
|
||
|
||
isDataAvailable = true
|
||
|
||
// 批量添加到缓冲区
|
||
dataBuffer.addAll(newData)
|
||
|
||
val currentTime = System.currentTimeMillis()
|
||
|
||
// 批量处理 - 每次处理更多点,提高数据累积速度
|
||
if (currentTime - lastUpdateTime >= updateInterval) {
|
||
if (dataBuffer.isNotEmpty()) {
|
||
// 每次处理多个点,提高效率
|
||
val batchSize = minOf(10, dataBuffer.size) // 每次最多处理10个点
|
||
val batch = dataBuffer.take(batchSize)
|
||
dataBuffer.removeAll(batch)
|
||
|
||
dataPoints.addAll(batch)
|
||
|
||
// 限制数据点数量
|
||
if (dataPoints.size > maxDataPoints) {
|
||
dataPoints = dataPoints.takeLast(maxDataPoints).toMutableList()
|
||
}
|
||
|
||
// 更新数据范围 - 使用批量数据
|
||
updateDataRange(batch)
|
||
|
||
lastUpdateTime = currentTime
|
||
}
|
||
}
|
||
|
||
// 超高频绘制
|
||
if (currentTime - lastDrawTime >= drawInterval) {
|
||
invalidate()
|
||
lastDrawTime = currentTime
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新多通道数据(用于PPG等设备)
|
||
*/
|
||
fun updateMultiChannelData(channelData: List<List<Float>>) {
|
||
if (channelData.isEmpty() || !isMultiChannelMode) {
|
||
Log.d("DynamicChartView", "跳过多通道更新: channelData.isEmpty=${channelData.isEmpty()}, isMultiChannelMode=$isMultiChannelMode")
|
||
return
|
||
}
|
||
|
||
Log.d("DynamicChartView", "多通道数据更新: 通道数=${channelData.size}, 每个通道数据点=${channelData.map { it.size }}")
|
||
isDataAvailable = true
|
||
|
||
val currentTime = System.currentTimeMillis()
|
||
|
||
// 更新每个通道的数据
|
||
for (channelIndex in channelData.indices) {
|
||
if (channelIndex >= multiChannelDataBuffers.size) {
|
||
Log.w("DynamicChartView", "通道索引超出范围: $channelIndex >= ${multiChannelDataBuffers.size}")
|
||
continue
|
||
}
|
||
|
||
val channelBuffer = multiChannelDataBuffers[channelIndex]
|
||
channelBuffer.addAll(channelData[channelIndex])
|
||
Log.d("DynamicChartView", "通道$channelIndex 添加了${channelData[channelIndex].size}个数据点")
|
||
}
|
||
|
||
// 批量处理数据
|
||
if (currentTime - lastUpdateTime >= updateInterval) {
|
||
var hasDataToProcess = false
|
||
|
||
for (channelIndex in multiChannelDataBuffers.indices) {
|
||
val buffer = multiChannelDataBuffers[channelIndex]
|
||
if (buffer.isNotEmpty()) {
|
||
// 每次处理多个点
|
||
val batchSize = minOf(10, buffer.size)
|
||
val batch = buffer.take(batchSize)
|
||
buffer.removeAll(batch)
|
||
|
||
multiChannelDataPoints[channelIndex].addAll(batch)
|
||
|
||
// 限制数据点数量
|
||
if (multiChannelDataPoints[channelIndex].size > maxDataPoints) {
|
||
multiChannelDataPoints[channelIndex] = multiChannelDataPoints[channelIndex].takeLast(maxDataPoints).toMutableList()
|
||
}
|
||
|
||
hasDataToProcess = true
|
||
}
|
||
}
|
||
|
||
if (hasDataToProcess) {
|
||
// 更新数据范围 - 计算所有通道的数据范围
|
||
updateMultiChannelDataRange()
|
||
lastUpdateTime = currentTime
|
||
|
||
Log.d("DynamicChartView", "多通道数据处理完成: 通道数据点=${multiChannelDataPoints.map { it.size }}, 范围=${minValue}-${maxValue}")
|
||
}
|
||
}
|
||
|
||
// 超高频绘制
|
||
if (currentTime - lastDrawTime >= drawInterval) {
|
||
invalidate()
|
||
lastDrawTime = currentTime
|
||
}
|
||
}
|
||
|
||
private fun updateDataRange(batch: List<Float>) {
|
||
// 使用全部数据点计算范围,提供更准确的范围
|
||
if (dataPoints.isEmpty()) return
|
||
|
||
// 计算全部数据点的范围
|
||
minValue = dataPoints.minOrNull() ?: 0f
|
||
maxValue = dataPoints.maxOrNull() ?: 0f
|
||
|
||
// 确保有足够的显示范围
|
||
val range = maxValue - minValue
|
||
if (range < 0.1f) {
|
||
val center = (maxValue + minValue) / 2
|
||
minValue = center - 0.05f
|
||
maxValue = center + 0.05f
|
||
} else {
|
||
val margin = range * 0.1f
|
||
minValue -= margin
|
||
maxValue += margin
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新多通道数据范围
|
||
*/
|
||
private fun updateMultiChannelDataRange() {
|
||
if (multiChannelDataPoints.isEmpty()) {
|
||
Log.w("DynamicChartView", "多通道数据点为空,无法计算范围")
|
||
return
|
||
}
|
||
|
||
// 计算所有通道的最小值和最大值
|
||
var globalMin = Float.MAX_VALUE
|
||
var globalMax = Float.MIN_VALUE
|
||
var hasValidData = false
|
||
|
||
for (channelIndex in multiChannelDataPoints.indices) {
|
||
val channelData = multiChannelDataPoints[channelIndex]
|
||
if (channelData.isNotEmpty()) {
|
||
val channelMin = channelData.minOrNull() ?: 0f
|
||
val channelMax = channelData.maxOrNull() ?: 0f
|
||
globalMin = minOf(globalMin, channelMin)
|
||
globalMax = maxOf(globalMax, channelMax)
|
||
hasValidData = true
|
||
Log.d("DynamicChartView", "通道$channelIndex: 范围=${channelMin}-${channelMax}, 数据点=${channelData.size}")
|
||
}
|
||
}
|
||
|
||
if (!hasValidData) {
|
||
Log.w("DynamicChartView", "没有有效的多通道数据")
|
||
return
|
||
}
|
||
|
||
minValue = globalMin
|
||
maxValue = globalMax
|
||
|
||
Log.d("DynamicChartView", "多通道全局范围: ${minValue}-${maxValue}")
|
||
|
||
// 确保有足够的显示范围
|
||
val range = maxValue - minValue
|
||
if (range < 0.1f) {
|
||
val center = (maxValue + minValue) / 2
|
||
minValue = center - 0.05f
|
||
maxValue = center + 0.05f
|
||
} else {
|
||
val margin = range * 0.1f
|
||
minValue -= margin
|
||
maxValue += margin
|
||
}
|
||
|
||
Log.d("DynamicChartView", "多通道显示范围: ${minValue}-${maxValue}")
|
||
}
|
||
|
||
fun clearData() {
|
||
dataPoints.clear()
|
||
dataBuffer.clear()
|
||
// 清除多通道数据
|
||
for (channelData in multiChannelDataPoints) {
|
||
channelData.clear()
|
||
}
|
||
for (channelBuffer in multiChannelDataBuffers) {
|
||
channelBuffer.clear()
|
||
}
|
||
isDataAvailable = false
|
||
resetViewport()
|
||
invalidate()
|
||
}
|
||
|
||
/**
|
||
* 重置视口到默认状态
|
||
*/
|
||
fun resetViewport() {
|
||
scaleFactor = 1.0f
|
||
translateX = 0f
|
||
translateY = 0f
|
||
viewportStart = 0f
|
||
viewportEnd = 1.0f
|
||
invalidate()
|
||
}
|
||
|
||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||
scaleGestureDetector.onTouchEvent(event)
|
||
|
||
when (event.action) {
|
||
MotionEvent.ACTION_DOWN -> {
|
||
val x = event.x
|
||
val y = event.y
|
||
|
||
// 开始拖拽
|
||
isDragging = true
|
||
lastTouchX = x
|
||
lastTouchY = y
|
||
return true
|
||
}
|
||
|
||
MotionEvent.ACTION_MOVE -> {
|
||
if (isDragging) {
|
||
val deltaX = event.x - lastTouchX
|
||
val deltaY = event.y - lastTouchY
|
||
|
||
translateX += deltaX
|
||
translateY += deltaY
|
||
|
||
// 限制平移范围
|
||
val maxTranslate = width * 0.5f
|
||
translateX = translateX.coerceIn(-maxTranslate, maxTranslate)
|
||
translateY = translateY.coerceIn(-maxTranslate, maxTranslate)
|
||
|
||
lastTouchX = event.x
|
||
lastTouchY = event.y
|
||
invalidate()
|
||
return true
|
||
}
|
||
}
|
||
|
||
MotionEvent.ACTION_UP -> {
|
||
isDragging = false
|
||
return true
|
||
}
|
||
}
|
||
|
||
return super.onTouchEvent(event)
|
||
}
|
||
|
||
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||
scaleFactor *= detector.scaleFactor
|
||
scaleFactor = scaleFactor.coerceIn(0.2f, 5.0f)
|
||
invalidate()
|
||
return true
|
||
}
|
||
}
|
||
|
||
override fun onDraw(canvas: Canvas) {
|
||
super.onDraw(canvas)
|
||
|
||
val width = width.toFloat()
|
||
val height = height.toFloat()
|
||
|
||
// 应用变换
|
||
canvas.save()
|
||
canvas.translate(translateX, translateY)
|
||
canvas.scale(scaleFactor, scaleFactor, width / 2, height / 2)
|
||
|
||
// 绘制网格
|
||
drawGrid(canvas, width, height)
|
||
|
||
// 绘制标题和通道信息
|
||
drawTitle(canvas, width, height)
|
||
|
||
// 绘制数据统计
|
||
drawStats(canvas, width, height)
|
||
|
||
// 绘制全屏按钮
|
||
drawFullscreenButton(canvas, width, height)
|
||
|
||
// 如果没有数据,显示默认内容
|
||
if (!isDataAvailable) {
|
||
drawDefaultContent(canvas, width, height)
|
||
} else {
|
||
// 绘制曲线 - 检查单通道或多通道数据
|
||
val hasDataToDraw = if (isMultiChannelMode) {
|
||
multiChannelDataPoints.any { it.size > 1 }
|
||
} else {
|
||
dataPoints.size > 1
|
||
}
|
||
|
||
if (hasDataToDraw) {
|
||
drawCurve(canvas, width, height)
|
||
}
|
||
}
|
||
|
||
canvas.restore()
|
||
}
|
||
|
||
private fun drawGrid(canvas: Canvas, width: Float, height: Float) {
|
||
val padding = 60f
|
||
val drawWidth = width - 2 * padding
|
||
val drawHeight = height - 2 * padding
|
||
|
||
// 绘制水平网格线
|
||
val gridLines = 5
|
||
for (i in 0..gridLines) {
|
||
val y = padding + (drawHeight * i / gridLines)
|
||
canvas.drawLine(padding, y, width - padding, y, gridPaint)
|
||
}
|
||
|
||
// 绘制垂直网格线
|
||
for (i in 0..gridLines) {
|
||
val x = padding + (drawWidth * i / gridLines)
|
||
canvas.drawLine(x, padding, x, height - padding, gridPaint)
|
||
}
|
||
}
|
||
|
||
private fun drawTitle(canvas: Canvas, width: Float, height: Float) {
|
||
val title = "$chartTitle $channelIndex"
|
||
canvas.drawText(title, 10f, 25f, textPaint)
|
||
|
||
val deviceInfo = "$deviceType (${sampleRate.toInt()}Hz)"
|
||
canvas.drawText(deviceInfo, 10f, 50f, textPaint)
|
||
|
||
// 显示当前缩放和时间窗口信息
|
||
val scaleInfo = "缩放: ${String.format("%.1f", scaleFactor)}x"
|
||
canvas.drawText(scaleInfo, width - 150f, 25f, textPaint)
|
||
|
||
val timeInfo = "时间窗口: ${timeWindow.toInt()}秒"
|
||
canvas.drawText(timeInfo, width - 150f, 50f, textPaint)
|
||
}
|
||
|
||
private fun drawStats(canvas: Canvas, width: Float, height: Float) {
|
||
val totalDataPoints = if (isMultiChannelMode) {
|
||
multiChannelDataPoints.sumOf { it.size }
|
||
} else {
|
||
dataPoints.size
|
||
}
|
||
|
||
val statsText = if (isMultiChannelMode) {
|
||
"数据点: ${totalDataPoints} (${multiChannelDataPoints.size}通道)"
|
||
} else {
|
||
"数据点: ${dataPoints.size}"
|
||
}
|
||
canvas.drawText(statsText, 10f, height - 35f, textPaint)
|
||
|
||
if (totalDataPoints > 0) {
|
||
val rangeText = "范围: ${String.format("%.2f", minValue)} - ${String.format("%.2f", maxValue)}"
|
||
canvas.drawText(rangeText, 10f, height - 15f, textPaint)
|
||
}
|
||
}
|
||
|
||
private fun drawDefaultContent(canvas: Canvas, width: Float, height: Float) {
|
||
val centerX = width / 2
|
||
val centerY = height / 2
|
||
|
||
val hintPaint = Paint().apply {
|
||
color = Color.GRAY
|
||
textSize = 16f
|
||
textAlign = Paint.Align.CENTER
|
||
isAntiAlias = true
|
||
}
|
||
|
||
canvas.drawText("等待数据...", centerX, centerY - 20f, hintPaint)
|
||
canvas.drawText("请先连接蓝牙设备", centerX, centerY + 20f, hintPaint)
|
||
canvas.drawText("然后点击'启动程序'显示数据", centerX, centerY + 60f, hintPaint)
|
||
}
|
||
|
||
private fun drawCurve(canvas: Canvas, width: Float, height: Float) {
|
||
val padding = 60f
|
||
val drawWidth = width - 2 * padding
|
||
val drawHeight = height - 2 * padding
|
||
|
||
if (isMultiChannelMode && multiChannelDataPoints.isNotEmpty()) {
|
||
Log.d("DynamicChartView", "多通道绘制: 通道数=${multiChannelDataPoints.size}, 数据点=${multiChannelDataPoints.map { it.size }}")
|
||
// 多通道绘制模式
|
||
for (channelIndex in multiChannelDataPoints.indices) {
|
||
val channelData = multiChannelDataPoints[channelIndex]
|
||
if (channelData.isEmpty()) {
|
||
Log.d("DynamicChartView", "通道$channelIndex 数据为空,跳过绘制")
|
||
continue
|
||
}
|
||
|
||
path.reset()
|
||
|
||
// 设置通道颜色
|
||
val channelPaint = Paint(paint).apply {
|
||
color = channelColors.getOrElse(channelIndex) { Color.GRAY }
|
||
strokeWidth = 2f
|
||
}
|
||
|
||
val xStep = if (channelData.size > 1) drawWidth / (channelData.size - 1) else drawWidth
|
||
|
||
for (i in channelData.indices) {
|
||
val x = padding + i * xStep
|
||
val normalizedValue = (channelData[i] - minValue) / (maxValue - minValue)
|
||
val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight
|
||
|
||
if (i == 0) {
|
||
path.moveTo(x, y)
|
||
} else {
|
||
path.lineTo(x, y)
|
||
}
|
||
}
|
||
|
||
canvas.drawPath(path, channelPaint)
|
||
}
|
||
} else {
|
||
// 单通道绘制模式
|
||
if (dataPoints.isEmpty()) return
|
||
|
||
path.reset()
|
||
val xStep = if (dataPoints.size > 1) drawWidth / (dataPoints.size - 1) else drawWidth
|
||
|
||
for (i in dataPoints.indices) {
|
||
val x = padding + i * xStep
|
||
val normalizedValue = (dataPoints[i] - minValue) / (maxValue - minValue)
|
||
val y = padding + (0.1f + normalizedValue * 0.8f) * drawHeight
|
||
|
||
if (i == 0) {
|
||
path.moveTo(x, y)
|
||
} else {
|
||
path.lineTo(x, y)
|
||
}
|
||
}
|
||
|
||
canvas.drawPath(path, paint)
|
||
}
|
||
}
|
||
|
||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||
|
||
val desiredHeight = 200 // 每个图表的高度
|
||
val height = resolveSize(desiredHeight, heightMeasureSpec)
|
||
setMeasuredDimension(widthMeasureSpec, height)
|
||
}
|
||
|
||
/**
|
||
* 获取当前图表数据
|
||
*/
|
||
fun getCurrentData(): List<Float> {
|
||
return dataPoints.toList()
|
||
}
|
||
|
||
/**
|
||
* 获取数据统计信息
|
||
*/
|
||
fun getDataStats(): Map<String, Any> {
|
||
val totalDataPoints = if (isMultiChannelMode) {
|
||
multiChannelDataPoints.sumOf { it.size }
|
||
} else {
|
||
dataPoints.size
|
||
}
|
||
|
||
return mapOf(
|
||
"dataPoints" to totalDataPoints,
|
||
"multiChannelMode" to isMultiChannelMode,
|
||
"channelCount" to if (isMultiChannelMode) multiChannelDataPoints.size else 1,
|
||
"minValue" to minValue,
|
||
"maxValue" to maxValue,
|
||
"range" to (maxValue - minValue),
|
||
"isDataAvailable" to isDataAvailable,
|
||
"scaleFactor" to scaleFactor,
|
||
"timeWindow" to timeWindow
|
||
)
|
||
}
|
||
}
|