Initial commit of akmon project
This commit is contained in:
482
uts/utils/chartDataUtils.uts
Normal file
482
uts/utils/chartDataUtils.uts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* 健康数据图表处理工具
|
||||
* 提供将原始健康数据转换为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}点`
|
||||
}
|
||||
Reference in New Issue
Block a user