/** * 健康数据图表处理工具 * 提供将原始健康数据转换为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() 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() 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() 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() 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}点` }