482 lines
16 KiB
Plaintext
482 lines
16 KiB
Plaintext
/**
|
||
* 健康数据图表处理工具
|
||
* 提供将原始健康数据转换为ak-charts显示格式的功能
|
||
*/
|
||
|
||
import type { HealthData } from '../../uni_modules/ak-sbsrv/utssdk/interface.uts'
|
||
import type { ChartOption, ChartType } from '../../uni_modules/ak-charts/interface.uts'
|
||
|
||
type MultiMetricBucket = {
|
||
heart?: number[]
|
||
spo2?: number[]
|
||
speed?: number[]
|
||
steps?: number[]
|
||
}
|
||
|
||
// Re-export ChartOption for use in other modules
|
||
export type { ChartOption, ChartType }
|
||
|
||
/**
|
||
* 心率数据处理配置
|
||
*/
|
||
export type HeartRateChartConfig = {
|
||
maxPoints?: number; // 最大显示点数,默认50
|
||
frequency?: number; // 数据频率控制,每N个点取一个,默认1(全部)
|
||
color?: string; // 图表颜色,默认'#FF6384'
|
||
}
|
||
|
||
/**
|
||
* 血氧数据处理配置
|
||
*/
|
||
export type Spo2ChartConfig = {
|
||
maxPoints?: number; // 最大显示点数,默认50
|
||
frequency?: number; // 数据频率控制,默认1
|
||
color?: string; // 图表颜色,默认'#36A2EB'
|
||
}
|
||
|
||
/**
|
||
* 步数数据处理配置
|
||
*/
|
||
export type StepsChartConfig = {
|
||
maxPoints?: number; // 最大显示点数,默认50
|
||
frequency?: number; // 数据频率控制,默认1
|
||
color?: string; // 图表颜色,默认'#FFCE56'
|
||
}
|
||
|
||
/**
|
||
* 处理心率数据为图表格式
|
||
* @param healthDataArray 健康数据数组
|
||
* @param config 配置选项
|
||
* @returns ChartOption对象
|
||
*/
|
||
export function processHeartRateData(
|
||
healthDataArray: HealthData[],
|
||
config: HeartRateChartConfig = {}
|
||
): ChartOption {
|
||
const {
|
||
maxPoints = 100, // 增加到100个点
|
||
frequency = 1,
|
||
color = '#FF6384'
|
||
} = config
|
||
|
||
// 兼容性:如果传入frequency作为时间间隔(1或5秒),则使用时间分桶
|
||
// frequency字段 legacy 用法仍然支持按索引采样
|
||
const freq = frequency
|
||
const useBucket = freq >= 1 && freq <= 10
|
||
|
||
if (!useBucket) {
|
||
// legacy 行为:按索引采样
|
||
const heartRateData = healthDataArray
|
||
.filter(data => data.type === 'heart' && data.heartRate != null)
|
||
.filter((_, index) => index % frequency === 0)
|
||
.slice(-maxPoints)
|
||
|
||
const data = heartRateData.map(item => item.heartRate!)
|
||
const labels = heartRateData.map((_, index) => ((index + 1) % 10 === 0 ? `${index + 1}` : ''))
|
||
return { type: 'line' as ChartType, data, labels, color }
|
||
}
|
||
|
||
// 使用时间分桶(默认1s或5s): 将心率按时间区间取最大值
|
||
const intervalSeconds = Math.max(1, Math.floor(freq))
|
||
const intervalMs = intervalSeconds * 1000
|
||
const buckets = new Map<number, number[]>()
|
||
for (let i = 0; i < healthDataArray.length; i++) {
|
||
const d = healthDataArray[i]
|
||
if (d.type !== 'heart' || d.heartRate == null) continue
|
||
const timestamp = d.timestamp
|
||
const ts = (timestamp == null) ? Date.now() : (typeof timestamp === 'number' ? timestamp : new Date(timestamp as string).getTime())
|
||
const key = Math.floor(ts / intervalMs) * intervalMs
|
||
if (!buckets.has(key)) buckets.set(key, [])
|
||
const heartRateValue = d.heartRate != null ? Number.parseFloat(d.heartRate.toString()) : 0
|
||
buckets.get(key)!.push(heartRateValue)
|
||
}
|
||
|
||
// 按时间排序并只取最新maxPoints
|
||
const keys: number[] = []
|
||
buckets.forEach((_, key) => { keys.push(key) })
|
||
keys.sort((a, b) => a - b)
|
||
const slicedKeys = keys.slice(-maxPoints)
|
||
|
||
const data: number[] = []
|
||
const labels: string[] = []
|
||
for (let i = 0; i < slicedKeys.length; i++) {
|
||
const key = slicedKeys[i]
|
||
const arr = (buckets.get(key) != null) ? buckets.get(key)! : []
|
||
let maxValue = NaN
|
||
for (let j = 0; j < arr.length; j++) {
|
||
const num = arr[j] as number
|
||
if (isNaN(num)) continue
|
||
if (isNaN(maxValue) || num > maxValue) {
|
||
maxValue = num
|
||
}
|
||
}
|
||
data.push(maxValue)
|
||
const labelDate = new Date(key)
|
||
const ss = labelDate.getSeconds().toString().padStart(2, '0')
|
||
const mm = labelDate.getMinutes().toString().padStart(2, '0')
|
||
labels.push(`${mm}:${ss}`)
|
||
}
|
||
return { type: 'line' as ChartType, data, labels, color }
|
||
}
|
||
|
||
/**
|
||
* 处理血氧数据为图表格式
|
||
* @param healthDataArray 健康数据数组
|
||
* @param config 配置选项
|
||
* @returns ChartOption对象
|
||
*/
|
||
export function processSpo2Data(
|
||
healthDataArray: HealthData[],
|
||
config: Spo2ChartConfig = {}
|
||
): ChartOption {
|
||
const {
|
||
maxPoints = 100, // 增加到100个点
|
||
frequency = 1,
|
||
color = '#36A2EB'
|
||
} = config
|
||
// 使用时间分桶(如果frequency为秒级1或5)取区间内最大值(或最后值)
|
||
const freq = frequency
|
||
const useBucket = freq >= 1 && freq <= 10
|
||
if (!useBucket) {
|
||
const spo2Data = healthDataArray
|
||
.filter(data => data.type === 'spo2' && data.spo2 != null)
|
||
.filter((_, index) => index % frequency === 0)
|
||
.slice(-maxPoints)
|
||
const data = spo2Data.map(item => item.spo2!)
|
||
const labels = spo2Data.map((_, index) => ((index + 1) % 10 === 0 ? `${index + 1}` : ''))
|
||
return { type: 'line' as ChartType, data, labels, color }
|
||
}
|
||
|
||
const intervalSeconds = Math.max(1, Math.floor(freq))
|
||
const intervalMs = intervalSeconds * 1000
|
||
const buckets = new Map<number, number[]>()
|
||
for (let i = 0; i < healthDataArray.length; i++) {
|
||
const d = healthDataArray[i]
|
||
if (d.type !== 'spo2' || d.spo2 == null) continue
|
||
const timestamp = d.timestamp
|
||
const ts = (timestamp == null) ? Date.now() : (typeof timestamp === 'number' ? timestamp : new Date(timestamp as string).getTime())
|
||
const key = Math.floor(ts / intervalMs) * intervalMs
|
||
if (!buckets.has(key)) buckets.set(key, [])
|
||
const spo2Value = d.spo2 != null ? Number.parseFloat(d.spo2.toString()) : 0
|
||
buckets.get(key)!.push(spo2Value)
|
||
}
|
||
const keys: number[] = []
|
||
buckets.forEach((_, key) => { keys.push(key) })
|
||
keys.sort((a, b) => a - b)
|
||
const slicedKeys = keys.slice(-maxPoints)
|
||
|
||
const data: number[] = []
|
||
const labels: string[] = []
|
||
for (let i = 0; i < slicedKeys.length; i++) {
|
||
const key = slicedKeys[i]
|
||
const bucketValue = buckets.get(key)
|
||
const arr = (bucketValue != null) ? bucketValue : []
|
||
let maxValue = NaN
|
||
for (let j = 0; j < arr.length; j++) {
|
||
const num = arr[j] as number
|
||
if (isNaN(num)) continue
|
||
if (isNaN(maxValue) || num > maxValue) {
|
||
maxValue = num
|
||
}
|
||
}
|
||
data.push(maxValue)
|
||
const labelDate = new Date(key)
|
||
const ss = labelDate.getSeconds().toString().padStart(2, '0')
|
||
const mm = labelDate.getMinutes().toString().padStart(2, '0')
|
||
labels.push(`${mm}:${ss}`)
|
||
}
|
||
return { type: 'line' as ChartType, data, labels, color }
|
||
}
|
||
|
||
/**
|
||
* 处理步数数据为图表格式
|
||
* @param healthDataArray 健康数据数组
|
||
* @param config 配置选项
|
||
* @returns ChartOption对象
|
||
*/
|
||
export function processStepsData(
|
||
healthDataArray: HealthData[],
|
||
config: StepsChartConfig = {}
|
||
): ChartOption {
|
||
const {
|
||
maxPoints = 100, // 增加到100个点
|
||
frequency = 1,
|
||
color = '#FFCE56'
|
||
} = config
|
||
// 步数通常以累计值或增量形式出现,按时间区间取最后一个值(或累加)
|
||
const freq = frequency
|
||
const useBucket = freq >= 1 && freq <= 10
|
||
if (!useBucket) {
|
||
const stepsData = healthDataArray
|
||
.filter(data => data.type === 'steps' && data.steps != null)
|
||
.filter((_, index) => index % frequency === 0)
|
||
.slice(-maxPoints)
|
||
const data = stepsData.map(item => item.steps!)
|
||
const labels = stepsData.map((_, index) => ((index + 1) % 10 === 0 ? `${index + 1}` : ''))
|
||
return { type: 'bar' as ChartType, data, labels, color }
|
||
}
|
||
|
||
const intervalSeconds = Math.max(1, Math.floor(freq))
|
||
const intervalMs = intervalSeconds * 1000
|
||
const buckets = new Map<number, number[]>()
|
||
for (let i = 0; i < healthDataArray.length; i++) {
|
||
const d = healthDataArray[i]
|
||
if (d.type !== 'steps' || d.steps == null) continue
|
||
const ts = (d.timestamp == null) ? Date.now() : (typeof d.timestamp === 'number' ? d.timestamp : new Date(d.timestamp as string).getTime())
|
||
const key = Math.floor(ts / intervalMs) * intervalMs
|
||
if (!buckets.has(key)) buckets.set(key, [])
|
||
const stepsValue = d.steps != null ? Number.parseFloat(d.steps.toString()) : 0
|
||
buckets.get(key)!.push(stepsValue)
|
||
}
|
||
const keys: number[] = []
|
||
buckets.forEach((_, key) => { keys.push(key) })
|
||
keys.sort((a, b) => a - b)
|
||
const slicedKeys = keys.slice(-maxPoints)
|
||
|
||
const data: number[] = []
|
||
const labels: string[] = []
|
||
for (let i = 0; i < slicedKeys.length; i++) {
|
||
const key = slicedKeys[i]
|
||
const bucketValue = buckets.get(key)
|
||
const arr = (bucketValue != null) ? bucketValue : []
|
||
let lastValue = NaN
|
||
if (arr.length > 0) {
|
||
const candidate = Number.parseFloat(arr[arr.length - 1].toString())
|
||
lastValue = isNaN(candidate) ? NaN : candidate
|
||
}
|
||
data.push(lastValue)
|
||
const labelDate = new Date(key)
|
||
const ss = labelDate.getSeconds().toString().padStart(2, '0')
|
||
const mm = labelDate.getMinutes().toString().padStart(2, '0')
|
||
labels.push(`${mm}:${ss}`)
|
||
}
|
||
return { type: 'bar' as ChartType, data, labels, color }
|
||
}
|
||
|
||
/**
|
||
* 创建对齐的多指标图表数据(心率、血氧、速度、步数)
|
||
* 返回一个数组,包含每个指标对应的 ChartOption,且 labels 保持一致
|
||
*/
|
||
export function processMultiMetricCharts(
|
||
healthDataArray: HealthData[],
|
||
intervalSeconds: number = 1,
|
||
maxPoints: number = 100,
|
||
metrics: ('heart' | 'spo2' | 'steps' | 'speed')[] = ['heart', 'spo2', 'steps']
|
||
): ChartOption[] {
|
||
const intervalMs = Math.max(1, intervalSeconds) * 1000
|
||
const buckets = new Map<number, MultiMetricBucket>()
|
||
|
||
for (let i = 0; i < healthDataArray.length; i++) {
|
||
const d = healthDataArray[i]
|
||
const ts = (d.timestamp == null) ? Date.now() : (typeof d.timestamp === 'number' ? d.timestamp : new Date(d.timestamp).getTime())
|
||
const key = Math.floor(ts / intervalMs) * intervalMs
|
||
if (!buckets.has(key)) buckets.set(key, {})
|
||
const entry = buckets.get(key)!
|
||
if (d.type === 'heart' && d.heartRate != null) {
|
||
if (!entry.heart) entry.heart = []
|
||
const heartRateValue = d.heartRate != null ? Number.parseFloat(d.heartRate.toString()) : 0
|
||
entry.heart.push(heartRateValue)
|
||
}
|
||
if (d.type === 'spo2' && d.spo2 != null) {
|
||
if (!entry.spo2) entry.spo2 = []
|
||
const spo2Value = d.spo2 != null ? Number.parseFloat(d.spo2.toString()) : 0
|
||
entry.spo2.push(spo2Value)
|
||
}
|
||
if ((d.type === 'speed' || d.type === 'heart') && (d as any).speed != null) {
|
||
if (!entry.speed) entry.speed = []
|
||
const speedValue = (d as any).speed != null ? Number.parseFloat((d as any).speed.toString()) : 0
|
||
entry.speed.push(speedValue)
|
||
}
|
||
if (d.type === 'steps' && d.steps != null) {
|
||
if (!entry.steps) entry.steps = []
|
||
const stepsValue = d.steps != null ? Number.parseFloat(d.steps.toString()) : 0
|
||
entry.steps.push(stepsValue)
|
||
}
|
||
}
|
||
|
||
const keys: number[] = []
|
||
buckets.forEach((_, key) => { keys.push(key) })
|
||
keys.sort((a, b) => a - b)
|
||
const slicedKeys = keys.slice(-maxPoints)
|
||
const labels: string[] = []
|
||
for (let i = 0; i < slicedKeys.length; i++) {
|
||
const key = slicedKeys[i]
|
||
const labelDate = new Date(key)
|
||
const ss = labelDate.getSeconds().toString().padStart(2, '0')
|
||
const mm = labelDate.getMinutes().toString().padStart(2, '0')
|
||
labels.push(`${mm}:${ss}`)
|
||
}
|
||
|
||
const results: ChartOption[] = []
|
||
if (metrics.includes('heart')) {
|
||
const data: number[] = []
|
||
for (let i = 0; i < slicedKeys.length; i++) {
|
||
const key = slicedKeys[i]
|
||
const bucketValue = buckets.get(key)
|
||
const arr = (bucketValue?.heart != null) ? bucketValue.heart : []
|
||
let maxValue = NaN
|
||
for (let j = 0; j < arr.length; j++) {
|
||
const num = Number.parseFloat(arr[j].toString())
|
||
if (isNaN(num)) continue
|
||
if (isNaN(maxValue) || num > maxValue) {
|
||
maxValue = num
|
||
}
|
||
}
|
||
data.push(maxValue)
|
||
}
|
||
results.push({ type: 'line' as ChartType, data, labels, color: '#FF6B6B' })
|
||
}
|
||
if (metrics.includes('spo2')) {
|
||
const data: number[] = []
|
||
for (let i = 0; i < slicedKeys.length; i++) {
|
||
const key = slicedKeys[i]
|
||
const bucketValue = buckets.get(key)
|
||
const arr = (bucketValue?.spo2 != null) ? bucketValue.spo2 : []
|
||
let maxValue = NaN
|
||
for (let j = 0; j < arr.length; j++) {
|
||
const num = Number(arr[j])
|
||
if (isNaN(num)) continue
|
||
if (isNaN(maxValue) || num > maxValue) {
|
||
maxValue = num
|
||
}
|
||
}
|
||
data.push(maxValue)
|
||
}
|
||
results.push({ type: 'line' as ChartType, data, labels, color: '#2196F3' })
|
||
}
|
||
if (metrics.includes('speed')) {
|
||
const data: number[] = []
|
||
for (let i = 0; i < slicedKeys.length; i++) {
|
||
const key = slicedKeys[i]
|
||
const bucketValue = buckets.get(key)
|
||
const arr = (bucketValue?.speed != null) ? bucketValue.speed : []
|
||
let maxValue = NaN
|
||
for (let j = 0; j < arr.length; j++) {
|
||
const num = Number(arr[j])
|
||
if (isNaN(num)) continue
|
||
if (isNaN(maxValue) || num > maxValue) {
|
||
maxValue = num
|
||
}
|
||
}
|
||
data.push(maxValue)
|
||
}
|
||
results.push({ type: 'line' as ChartType, data, labels, color: '#FFA726' })
|
||
}
|
||
if (metrics.includes('steps')) {
|
||
const data: number[] = []
|
||
for (let i = 0; i < slicedKeys.length; i++) {
|
||
const key = slicedKeys[i]
|
||
const bucketValue = buckets.get(key)
|
||
const arr = (bucketValue?.steps != null) ? bucketValue.steps : []
|
||
let lastValue = NaN
|
||
if (arr.length > 0) {
|
||
const candidate = Number(arr[arr.length - 1])
|
||
lastValue = isNaN(candidate) ? NaN : candidate
|
||
}
|
||
data.push(lastValue)
|
||
}
|
||
results.push({ type: 'bar' as ChartType, data, labels, color: '#4CAF50' })
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* 创建多指标组合图表数据
|
||
* @param healthDataArray 健康数据数组
|
||
* @param metrics 要包含的指标数组
|
||
* @param maxPoints 最大显示点数
|
||
* @returns ChartOption对象数组,每个指标一个图表
|
||
*/
|
||
export function createMultiMetricCharts(
|
||
healthDataArray: HealthData[],
|
||
metrics: ('heart' | 'spo2' | 'steps')[] = ['heart', 'spo2', 'steps'],
|
||
maxPoints: number = 100
|
||
): ChartOption[] {
|
||
const charts: ChartOption[] = []
|
||
|
||
if (metrics.includes('heart')) {
|
||
charts.push(processHeartRateData(healthDataArray, { maxPoints }))
|
||
}
|
||
|
||
if (metrics.includes('spo2')) {
|
||
charts.push(processSpo2Data(healthDataArray, { maxPoints }))
|
||
}
|
||
|
||
if (metrics.includes('steps')) {
|
||
charts.push(processStepsData(healthDataArray, { maxPoints }))
|
||
}
|
||
|
||
return charts
|
||
}
|
||
|
||
/**
|
||
* 获取数据的最新值
|
||
* @param healthDataArray 健康数据数组
|
||
* @param dataType 数据类型
|
||
* @returns 最新值或null
|
||
*/
|
||
export function getLatestValue(
|
||
healthDataArray: HealthData[],
|
||
dataType: 'heart' | 'spo2' | 'steps'
|
||
): number | null {
|
||
const filteredData = healthDataArray
|
||
.filter(data => data.type === dataType)
|
||
.filter(data => {
|
||
switch (dataType) {
|
||
case 'heart': return data.heartRate != null
|
||
case 'spo2': return data.spo2 != null
|
||
case 'steps': return data.steps != null
|
||
default: return false
|
||
}
|
||
})
|
||
|
||
if (filteredData.length === 0) return null
|
||
|
||
const latest = filteredData[filteredData.length - 1]
|
||
switch (dataType) {
|
||
case 'heart': return latest.heartRate!
|
||
case 'spo2': return latest.spo2!
|
||
case 'steps': return latest.steps!
|
||
default: return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查是否有足够的数据用于图表显示
|
||
* @param healthDataArray 健康数据数组
|
||
* @param dataType 数据类型
|
||
* @param minPoints 最少点数,默认3
|
||
* @returns 是否有足够数据
|
||
*/
|
||
export function hasEnoughDataForChart(
|
||
healthDataArray: HealthData[],
|
||
dataType: 'heart' | 'spo2' | 'steps',
|
||
minPoints: number = 3
|
||
): boolean {
|
||
const filteredData = healthDataArray.filter(data => data.type === dataType)
|
||
return filteredData.length >= minPoints
|
||
}
|
||
|
||
/**
|
||
* 测试数据处理功能(用于开发调试)
|
||
* @returns 测试结果
|
||
*/
|
||
export function testChartDataProcessing(): string {
|
||
// 创建测试数据
|
||
const testData: HealthData[] = [
|
||
{ type: 'heart', heartRate: 72, timestamp: Date.now() },
|
||
{ type: 'spo2', spo2: 98, timestamp: Date.now() },
|
||
{ type: 'steps', steps: 150, timestamp: Date.now() },
|
||
{ type: 'heart', heartRate: 75, timestamp: Date.now() },
|
||
{ type: 'spo2', spo2: 97, timestamp: Date.now() },
|
||
{ type: 'steps', steps: 200, timestamp: Date.now() }
|
||
]
|
||
|
||
// 测试心率数据处理
|
||
const heartChart = processHeartRateData(testData, { maxPoints: 100 })
|
||
const spo2Chart = processSpo2Data(testData, { maxPoints: 100 })
|
||
const stepsChart = processStepsData(testData, { maxPoints: 100 })
|
||
|
||
return `测试完成 - 心率: ${heartChart.data.length}点, 血氧: ${spo2Chart.data.length}点, 步数: ${stepsChart.data.length}点`
|
||
} |