Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View 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}点`
}