Files
akmon/uts/utils/chartDataUtils.uts
2026-01-20 08:04:15 +08:00

482 lines
16 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 健康数据图表处理工具
* 提供将原始健康数据转换为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}点`
}