Files

981 lines
42 KiB
JavaScript

const el = (id) => document.getElementById(id)
const fmtNum = (v, d = 1) => {
if (v === null || v === undefined || Number.isNaN(v)) return '-'
return Number(v).toFixed(d)
}
const fmtDate = (s) => {
if (!s) return '-'
return String(s).slice(0, 10)
}
const loadScript = (src) => new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = src
s.async = true
s.onload = () => resolve()
s.onerror = () => reject()
document.head.appendChild(s)
})
const ensureChart = async () => !!window.Chart
const state = {
charts: {},
lastData: null,
activeTab: 'default'
}
const setText = (id, value) => {
const node = el(id)
if (node) node.textContent = value
}
const avgOf = (arr) => {
const nums = (arr || []).filter(v => Number.isFinite(v))
if (!nums.length) return null
return nums.reduce((a, b) => a + b, 0) / nums.length
}
const sumOf = (arr) => (arr || []).reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
const firstValue = (arr) => {
for (let i = 0; i < arr.length; i += 1) {
if (Number.isFinite(arr[i])) return arr[i]
}
return null
}
const getTrendData = (data) => ({
days: Number(el('daysRange').value || 90),
labels: data.trends.dates || [],
hr: data.trends.hr_avg || [],
resp: data.trends.resp_avg || [],
weight: data.trends.weight || [],
distance: data.trends.distance || [],
sleep: data.trends.sleep_hours || [],
stand: data.trends.stand_hours || [],
workout: data.trends.workout_minutes || [],
hrv: data.trends.hrv_sdnn || [],
restingHr: data.trends.resting_hr || [],
walkingHr: data.trends.walking_hr || [],
sleepRem: data.trends.sleep_rem || [],
sleepCore: data.trends.sleep_core || [],
sleepDeep: data.trends.sleep_deep || [],
sleepAwake: data.trends.sleep_awake || []
})
const getTypeSummary = (data) => {
const shortType = (t) => String(t || '').replace('HKQuantityTypeIdentifier', '').replace('HKCategoryTypeIdentifier', '')
const types = data.compare.types || []
return {
typeLabels: types.map(t => shortType(t.type)),
typeValues: types.map(t => t.record_count || 0)
}
}
const updateSummary = (data) => {
setText('statRecords', data.summary.total_records || 0)
setText('statTypes', data.summary.type_count || 0)
setText('statRoutes', data.summary.route_count || 0)
setText('statEcg', data.summary.ecg_count || 0)
const dateRange = data.summary.date_min ? `${fmtDate(data.summary.date_min)} ~ ${fmtDate(data.summary.date_max)}` : '-'
setText('statDateRange', dateRange)
setText('statLastImport', data.summary.last_import ? `最近同步 ${fmtDate(data.summary.last_import)}` : '-')
setText('statAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
setText('statAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
setText('statDistance', fmtNum(data.cards.total_distance_km, 1))
const weightUnit = data.latest.weight_unit || 'kg'
const heightUnitRaw = data.latest.height_unit || 'cm'
let heightUnit = heightUnitRaw
let heightValue = data.latest.height_cm
if (heightUnitRaw === 'm' && Number.isFinite(heightValue)) {
heightValue = heightValue * 100
heightUnit = 'cm'
}
setText('statWeight', fmtNum(data.latest.weight_kg, 1))
setText('statHeight', fmtNum(heightValue, 1))
setText('statWeightUnit', weightUnit)
setText('statHeightUnit', heightUnit)
setText('statSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
setText('statStand', fmtNum(data.cards.avg_stand_7d_h, 1))
setText('statWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
}
const destroyCharts = () => {
Object.values(state.charts).forEach(c => {
if (c && typeof c.destroy === 'function') c.destroy()
})
state.charts = {}
}
const drawSimpleLine = (canvas, labels, series, colors) => {
const ctx = canvas.getContext('2d')
const width = canvas.clientWidth || canvas.width
const height = canvas.clientHeight || canvas.height
canvas.width = width
canvas.height = height
ctx.clearRect(0, 0, width, height)
const allValues = series.flat().filter(v => Number.isFinite(v))
if (!allValues.length) return false
const min = Math.min(...allValues)
const max = Math.max(...allValues)
const pad = 24
const xStep = (width - pad * 2) / Math.max(1, labels.length - 1)
const scaleY = (v) => {
if (max === min) return height / 2
return height - pad - ((v - min) / (max - min)) * (height - pad * 2)
}
series.forEach((values, idx) => {
ctx.beginPath()
ctx.strokeStyle = colors[idx] || '#38bdf8'
ctx.lineWidth = 2
values.forEach((v, i) => {
if (!Number.isFinite(v)) return
const x = pad + xStep * i
const y = scaleY(v)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
})
ctx.stroke()
})
return true
}
const drawSimpleBars = (canvas, labels, values) => {
const ctx = canvas.getContext('2d')
const width = canvas.clientWidth || canvas.width
const height = canvas.clientHeight || canvas.height
canvas.width = width
canvas.height = height
ctx.clearRect(0, 0, width, height)
const nums = values.filter(v => Number.isFinite(v))
if (!nums.length) return false
const max = Math.max(...nums, 1)
const pad = 24
const barWidth = (width - pad * 2) / Math.max(1, values.length)
values.forEach((v, i) => {
if (!Number.isFinite(v)) return
const h = ((v / max) * (height - pad * 2))
const x = pad + i * barWidth + barWidth * 0.2
const y = height - pad - h
ctx.fillStyle = '#60a5fa'
ctx.fillRect(x, y, barWidth * 0.6, h)
})
return true
}
const drawSimplePie = (canvas, values, colors) => {
const ctx = canvas.getContext('2d')
const width = canvas.clientWidth || canvas.width
const height = canvas.clientHeight || canvas.height
canvas.width = width
canvas.height = height
ctx.clearRect(0, 0, width, height)
const total = values.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
if (!total) return false
let start = -Math.PI / 2
values.forEach((v, i) => {
const value = Number.isFinite(v) ? v : 0
const angle = (value / total) * Math.PI * 2
ctx.beginPath()
ctx.moveTo(width / 2, height / 2)
ctx.fillStyle = colors[i % colors.length]
ctx.arc(width / 2, height / 2, Math.min(width, height) / 2 - 6, start, start + angle)
ctx.fill()
start += angle
})
return true
}
const drawSimpleScatter = (canvas, points, color = '#38bdf8') => {
const ctx = canvas.getContext('2d')
const width = canvas.clientWidth || canvas.width
const height = canvas.clientHeight || canvas.height
canvas.width = width
canvas.height = height
ctx.clearRect(0, 0, width, height)
if (!points.length) return false
const xs = points.map(p => p.x).filter(v => Number.isFinite(v))
const ys = points.map(p => p.y).filter(v => Number.isFinite(v))
if (!xs.length || !ys.length) return false
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const pad = 24
const scaleX = (v) => {
if (maxX === minX) return width / 2
return pad + ((v - minX) / (maxX - minX)) * (width - pad * 2)
}
const scaleY = (v) => {
if (maxY === minY) return height / 2
return height - pad - ((v - minY) / (maxY - minY)) * (height - pad * 2)
}
ctx.fillStyle = color
points.forEach(p => {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) return
const x = scaleX(p.x)
const y = scaleY(p.y)
ctx.beginPath()
ctx.arc(x, y, 3, 0, Math.PI * 2)
ctx.fill()
})
return true
}
const renderDefaultCharts = async (data) => {
const trends = getTrendData(data)
const { typeLabels, typeValues } = getTypeSummary(data)
const { days, labels, hr, resp, weight, distance, sleep, stand, workout, hrv, restingHr, walkingHr, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
destroyCharts()
const hasChart = await ensureChart()
if (hasChart) {
const vCtx = el('chartVitals').getContext('2d')
state.charts.vitals = new Chart(vCtx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: { x: { ticks: { maxTicksLimit: 6 } } }
}
})
const wCtx = el('chartWeight').getContext('2d')
state.charts.weight = new Chart(wCtx, {
type: 'line',
data: { labels, datasets: [{ label: '体重', data: weight, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.2)', tension: 0.35, pointRadius: 2 }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const dCtx = el('chartDistance').getContext('2d')
state.charts.distance = new Chart(dCtx, {
type: 'bar',
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#60a5fa' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const tCtx = el('chartTypes').getContext('2d')
state.charts.types = new Chart(tCtx, {
type: 'pie',
data: {
labels: typeLabels,
datasets: [{ data: typeValues, backgroundColor: ['#38bdf8', '#60a5fa', '#a78bfa', '#f472b6', '#34d399', '#facc15', '#fb7185', '#c084fc', '#22d3ee', '#f97316', '#4ade80', '#eab308'] }]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
})
const sCtx = el('chartSleep').getContext('2d')
state.charts.sleep = new Chart(sCtx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '睡眠小时', data: sleep, borderColor: '#34d399', backgroundColor: 'rgba(52,211,153,0.2)', tension: 0.35, pointRadius: 2 },
{ label: '站立小时', data: stand, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.2)', tension: 0.35, pointRadius: 2 }
]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const wCtx2 = el('chartWorkout').getContext('2d')
state.charts.workout = new Chart(wCtx2, {
type: 'bar',
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const ssCtx = el('chartSleepStages').getContext('2d')
state.charts.sleepStages = new Chart(ssCtx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { ticks: { maxTicksLimit: 6 }, stacked: true },
y: { stacked: true, title: { display: true, text: '小时' } }
}
}
})
const hhCtx = el('chartHeartHealth').getContext('2d')
state.charts.heartHealth = new Chart(hhCtx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: {
x: { ticks: { maxTicksLimit: 6 } },
y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'bpm' } },
y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
}
}
})
} else {
drawSimpleLine(el('chartVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
drawSimpleLine(el('chartWeight'), labels, [weight], ['#a78bfa'])
drawSimpleBars(el('chartDistance'), labels, distance)
drawSimplePie(el('chartTypes'), typeValues, ['#38bdf8', '#60a5fa', '#a78bfa', '#f472b6', '#34d399', '#facc15', '#fb7185', '#c084fc', '#22d3ee', '#f97316', '#4ade80', '#eab308'])
drawSimpleLine(el('chartSleep'), labels, [sleep, stand], ['#34d399', '#f59e0b'])
drawSimpleBars(el('chartWorkout'), labels, workout)
}
setText('chartVitalsMeta', `${days}天内平均心率与呼吸率`)
setText('chartWeightMeta', `${days}天体重变化`)
setText('chartDistanceMeta', `${days}天步行距离`)
setText('chartTypesMeta', `最多记录的 ${typeLabels.length} 类数据`)
setText('chartSleepMeta', `${days}天睡眠小时与站立小时`)
setText('chartWorkoutMeta', `${days}天运动分钟`)
setText('chartSleepStagesMeta', `${days}天睡眠分期 (REM/核心/深睡/清醒)`)
setText('chartHeartHealthMeta', `${days}天静息/步行心率与 HRV 趋势`)
}
const typeNameMap = {
HKQuantityTypeIdentifierStepCount: '步数',
HKQuantityTypeIdentifierActiveEnergyBurned: '活动能量',
HKQuantityTypeIdentifierAppleExerciseTime: '锻炼时间',
HKQuantityTypeIdentifierStandTime: '站立时间',
HKQuantityTypeIdentifierBasalEnergyBurned: '基础能量',
HKQuantityTypeIdentifierDistanceWalkingRunning: '步行跑步距离',
HKQuantityTypeIdentifierHeartRate: '心率',
HKQuantityTypeIdentifierRespiratoryRate: '呼吸率',
HKQuantityTypeIdentifierBodyMass: '体重',
HKQuantityTypeIdentifierHeight: '身高',
HKCategoryTypeIdentifierAppleStandHour: '站立小时',
HKCategoryTypeIdentifierSleepAnalysis: '睡眠'
}
const typeDisplayConfig = {
HKQuantityTypeIdentifierStepCount: { mode: 'daily_avg', label: '日均' },
HKQuantityTypeIdentifierActiveEnergyBurned: { mode: 'daily_avg', label: '日均' },
HKQuantityTypeIdentifierBasalEnergyBurned: { mode: 'daily_avg', label: '日均' },
HKQuantityTypeIdentifierDistanceWalkingRunning: { mode: 'daily_avg', label: '日均' },
HKQuantityTypeIdentifierAppleExerciseTime: { mode: 'daily_avg', label: '日均' },
HKQuantityTypeIdentifierStandTime: { mode: 'daily_avg', label: '日均' },
HKQuantityTypeIdentifierFlightsClimbed: { mode: 'daily_avg', label: '日均' },
HKQuantityTypeIdentifierDietaryEnergyConsumed: { mode: 'daily_avg', label: '日均' },
HKQuantityTypeIdentifierHeartRate: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierRestingHeartRate: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierWalkingHeartRateAverage: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierHeartRateVariabilitySDNN: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierRespiratoryRate: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierOxygenSaturation: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierBodyMass: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierHeight: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierBodyTemperature: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierEnvironmentalAudioExposure: { mode: 'avg', label: '平均' },
HKQuantityTypeIdentifierHeadphoneAudioExposure: { mode: 'avg', label: '平均' }
}
const renderTypeList = (data, targetId = 'typeList') => {
const box = el(targetId)
if (!box) return
box.innerHTML = ''
const types = data.compare.types || []
if (!types.length) {
box.innerHTML = '<div class="list-item"><span class="muted">暂无类型数据</span></div>'
return
}
types.slice(0, 12).forEach(item => {
const name = typeNameMap[item.type] || item.type.replace('HKQuantityTypeIdentifier', '').replace('HKCategoryTypeIdentifier', '')
const config = typeDisplayConfig[item.type] || { mode: 'count' }
let valueStr = ''
if (config.mode === 'daily_avg' && item.total_value && item.day_count) {
const val = item.total_value / Math.max(1, item.day_count)
valueStr = `${config.label} ${fmtNum(val, 1)}`
} else if (config.mode === 'avg' && item.avg_value) {
valueStr = `${config.label} ${fmtNum(item.avg_value, 1)}`
} else {
valueStr = `${item.record_count} 次`
}
const node = document.createElement('div')
node.className = 'list-item'
node.innerHTML = `
<div class="list-row">
<div class="list-title">${name}</div>
<div class="list-meta">${item.type}</div>
</div>
<span class="tag">${valueStr}${item.unit ? ` · ${item.unit}` : ''}</span>
`
box.appendChild(node)
})
}
const lastValue = (arr) => {
for (let i = arr.length - 1; i >= 0; i -= 1) {
if (Number.isFinite(arr[i])) return arr[i]
}
return null
}
const renderTrendList = (data, targetId = 'trendList') => {
const box = el(targetId)
if (!box) return
box.innerHTML = ''
const hr = data.trends.hr_avg || []
const resp = data.trends.resp_avg || []
const weight = data.trends.weight || []
const distance = data.trends.distance || []
const sleep = data.trends.sleep_hours || []
const stand = data.trends.stand_hours || []
const workout = data.trends.workout_minutes || []
const days = Math.max(1, data.trends.dates.length || 1)
const totalDistance = distance.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0)
const avgDistance = totalDistance / days
const maxHr = Math.max(...hr.filter(v => Number.isFinite(v)), 0)
const lastHr = lastValue(hr)
const lastResp = lastValue(resp)
const lastWeight = lastValue(weight)
const lastSleep = lastValue(sleep)
const lastStand = lastValue(stand)
const lastWorkout = lastValue(workout)
const items = [
{ title: '最近心率', value: lastHr, unit: 'bpm' },
{ title: '最近呼吸率', value: lastResp, unit: '次/分钟' },
{ title: '最近体重', value: lastWeight, unit: 'kg' },
{ title: '最高心率', value: maxHr, unit: 'bpm' },
{ title: '日均步行距离', value: avgDistance, unit: 'km' },
{ title: '最近睡眠', value: lastSleep, unit: '小时' },
{ title: '最近站立', value: lastStand, unit: '小时' },
{ title: '最近运动', value: lastWorkout, unit: '分钟' }
]
const hasData = hr.some(v => Number.isFinite(v)) || resp.some(v => Number.isFinite(v)) || weight.some(v => Number.isFinite(v)) || distance.some(v => Number.isFinite(v)) || sleep.some(v => Number.isFinite(v)) || stand.some(v => Number.isFinite(v)) || workout.some(v => Number.isFinite(v))
if (!hasData) {
box.innerHTML = '<div class="list-item"><span class="muted">暂无趋势数据</span></div>'
return
}
items.forEach(item => {
const node = document.createElement('div')
node.className = 'list-item'
node.innerHTML = `<span>${item.title}</span><span class="tag">${fmtNum(item.value, 1)} ${item.unit}</span>`
box.appendChild(node)
})
}
const renderAnomalies = (data, targetId = 'anomalyList') => {
const box = el(targetId)
if (!box) return
box.innerHTML = ''
const items = []
data.anomalies.hr.forEach(a => items.push({ type: '心率', date: a.date, value: a.value, unit: 'bpm' }))
data.anomalies.resp.forEach(a => items.push({ type: '呼吸率', date: a.date, value: a.value, unit: '次/分钟' }))
if (!items.length) {
box.innerHTML = '<div class="list-item"><span class="muted">暂无异常高值</span></div>'
return
}
items.slice(-10).reverse().forEach(item => {
const node = document.createElement('div')
node.className = 'list-item'
node.innerHTML = `<span>${item.type} ${fmtDate(item.date)}</span><span class="tag">${fmtNum(item.value, 1)} ${item.unit}</span>`
box.appendChild(node)
})
}
const renderDeep = (data, targetId = 'deepList') => {
const box = el(targetId)
if (!box) return
box.innerHTML = ''
const routes = data.deep.routes || []
const ecg = data.deep.ecg || []
const workouts = data.deep.workouts || []
if (!routes.length && !ecg.length && !workouts.length) {
box.innerHTML = '<div class="list-item"><span class="muted">暂无深度数据</span></div>'
return
}
workouts.forEach(r => {
const node = document.createElement('div')
node.className = 'list-item'
node.innerHTML = `<span>训练 ${fmtDate(r.start_time)}</span><span class="tag">${fmtNum(r.duration_min, 1)} 分钟 · ${fmtNum(r.distance_km, 2)} km</span>`
box.appendChild(node)
})
routes.forEach(r => {
const node = document.createElement('div')
node.className = 'list-item'
node.innerHTML = `<span>路线 ${fmtDate(r.route_time)}</span><span class="tag">${fmtNum(r.distance_km, 2)} km · ${fmtNum(r.duration_min, 1)} 分钟</span>`
box.appendChild(node)
})
ecg.forEach(r => {
const node = document.createElement('div')
node.className = 'list-item'
node.innerHTML = `<span>ECG ${fmtDate(r.record_time)}</span><span class="tag">${r.classification || '未知'} · ${fmtNum(r.sampling_rate, 0)} Hz</span>`
box.appendChild(node)
})
}
const renderHddSummary = (data) => {
const trends = getTrendData(data)
setText('hddAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
setText('hddAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
setText('hddAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
setText('hddAvgStand', fmtNum(data.cards.avg_stand_7d_h, 1))
setText('hddAvgWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
setText('hddDistance', fmtNum(sumOf(trends.distance), 1))
setText('hddRestingHr', fmtNum(avgOf(trends.restingHr), 1))
setText('hddHrv', fmtNum(avgOf(trends.hrv), 1))
}
const renderAhaSummary = (data) => {
const trends = getTrendData(data)
const sleepTotal = sumOf(trends.sleepRem) + sumOf(trends.sleepCore) + sumOf(trends.sleepDeep) + sumOf(trends.sleepAwake)
const remPct = sleepTotal ? (sumOf(trends.sleepRem) / sleepTotal) * 100 : null
const deepPct = sleepTotal ? (sumOf(trends.sleepDeep) / sleepTotal) * 100 : null
const weightFirst = firstValue(trends.weight)
const weightLast = lastValue(trends.weight)
const weightDelta = Number.isFinite(weightFirst) && Number.isFinite(weightLast) ? weightLast - weightFirst : null
setText('ahaRestingHr', fmtNum(avgOf(trends.restingHr), 1))
setText('ahaWalkingHr', fmtNum(avgOf(trends.walkingHr), 1))
setText('ahaHrv', fmtNum(avgOf(trends.hrv), 1))
setText('ahaRemPct', fmtNum(remPct, 1))
setText('ahaDeepPct', fmtNum(deepPct, 1))
setText('ahaWeightDelta', fmtNum(weightDelta, 2))
setText('ahaWorkoutTotal', fmtNum(sumOf(trends.workout), 0))
setText('ahaAnomalyCount', data.anomalies.hr ? data.anomalies.hr.length : 0)
}
const renderGrafanaSummary = (data) => {
const trends = getTrendData(data)
setText('grafanaAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
setText('grafanaAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
setText('grafanaAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
setText('grafanaAvgWorkout', fmtNum(data.cards.avg_workout_7d_min, 1))
setText('grafanaAvgStand', fmtNum(data.cards.avg_stand_7d_h, 1))
setText('grafanaDistance', fmtNum(sumOf(trends.distance), 1))
setText('grafanaRoutes', data.summary.route_count || 0)
setText('grafanaEcg', data.summary.ecg_count || 0)
}
const renderDhdSummary = (data) => {
const trends = getTrendData(data)
setText('dhdAvgHr', fmtNum(data.cards.avg_hr_7d, 1))
setText('dhdAvgResp', fmtNum(data.cards.avg_resp_7d, 1))
setText('dhdRestingHr', fmtNum(avgOf(trends.restingHr), 1))
setText('dhdWalkingHr', fmtNum(avgOf(trends.walkingHr), 1))
setText('dhdHrv', fmtNum(avgOf(trends.hrv), 1))
setText('dhdDistance', fmtNum(data.cards.total_distance_km, 1))
setText('dhdWeight', fmtNum(data.latest.weight_kg, 1))
setText('dhdWeightUnit', data.latest.weight_unit || 'kg')
setText('dhdAvgSleep', fmtNum(data.cards.avg_sleep_7d_h, 2))
}
const renderHddCharts = async (data) => {
const trends = getTrendData(data)
const { days, labels, hr, resp, workout, distance, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
destroyCharts()
const hasChart = await ensureChart()
if (hasChart) {
const vCtx = el('chartHddVitals').getContext('2d')
state.charts.hddVitals = new Chart(vCtx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
]
},
options: { responsive: true, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const aCtx = el('chartHddActivity').getContext('2d')
state.charts.hddActivity = new Chart(aCtx, {
type: 'bar',
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const sCtx = el('chartHddSleepStages').getContext('2d')
state.charts.hddSleepStages = new Chart(sCtx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
})
const dCtx = el('chartHddDistance').getContext('2d')
state.charts.hddDistance = new Chart(dCtx, {
type: 'bar',
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#60a5fa' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
} else {
drawSimpleLine(el('chartHddVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
drawSimpleBars(el('chartHddActivity'), labels, workout)
drawSimpleBars(el('chartHddDistance'), labels, distance)
}
setText('chartHddVitalsMeta', `${days}天心率与呼吸率`)
setText('chartHddActivityMeta', `${days}天运动分钟`)
setText('chartHddSleepStagesMeta', `${days}天睡眠分期`)
setText('chartHddDistanceMeta', `${days}天步行距离`)
}
const renderAhaCharts = async (data) => {
const trends = getTrendData(data)
const { days, labels, weight, workout, hrv, restingHr, walkingHr, sleepRem, sleepCore, sleepDeep, sleepAwake } = trends
destroyCharts()
const hasChart = await ensureChart()
if (hasChart) {
const hCtx = el('chartAhaHeart').getContext('2d')
state.charts.ahaHeart = new Chart(hCtx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: {
x: { ticks: { maxTicksLimit: 6 } },
y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'bpm' } },
y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
}
}
})
const sCtx = el('chartAhaSleepStages').getContext('2d')
state.charts.ahaSleepStages = new Chart(sCtx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
})
const wCtx = el('chartAhaWeight').getContext('2d')
state.charts.ahaWeight = new Chart(wCtx, {
type: 'line',
data: { labels, datasets: [{ label: '体重', data: weight, borderColor: '#a78bfa', backgroundColor: 'rgba(167,139,250,0.2)', tension: 0.35, pointRadius: 2 }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const aCtx = el('chartAhaWorkout').getContext('2d')
state.charts.ahaWorkout = new Chart(aCtx, {
type: 'bar',
data: { labels, datasets: [{ label: '分钟', data: workout, backgroundColor: '#fb7185' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
} else {
drawSimpleLine(el('chartAhaHeart'), labels, [restingHr, walkingHr], ['#f43f5e', '#ec4899'])
drawSimpleLine(el('chartAhaWeight'), labels, [weight], ['#a78bfa'])
drawSimpleBars(el('chartAhaWorkout'), labels, workout)
}
setText('chartAhaHeartMeta', `${days}天心脏恢复趋势`)
setText('chartAhaSleepStagesMeta', `${days}天睡眠结构`)
setText('chartAhaWeightMeta', `${days}天体重变化`)
setText('chartAhaWorkoutMeta', `${days}天运动分钟`)
}
const renderGrafanaCharts = async (data) => {
const trends = getTrendData(data)
const { days, labels, workout, distance, sleepRem, sleepCore, sleepDeep, sleepAwake, restingHr, walkingHr, hrv } = trends
const routes = (data.deep && data.deep.routes) ? data.deep.routes : []
const { typeLabels, typeValues } = getTypeSummary(data)
const sleepTotals = [sumOf(sleepRem), sumOf(sleepCore), sumOf(sleepDeep), sumOf(sleepAwake)]
const sleepLabels = ['REM', '核心', '深睡', '清醒']
const routePoints = routes.map(r => ({
x: Number(r.distance_km || 0),
y: Number(r.duration_min || 0)
})).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
destroyCharts()
const hasChart = await ensureChart()
if (hasChart) {
const aCtx = el('chartGrafanaActivity').getContext('2d')
state.charts.grafanaActivity = new Chart(aCtx, {
type: 'bar',
data: {
labels,
datasets: [
{ type: 'bar', label: '运动分钟', data: workout, backgroundColor: '#fb7185', yAxisID: 'y' },
{ type: 'line', label: '步行距离', data: distance, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: {
x: { ticks: { maxTicksLimit: 6 } },
y: { title: { display: true, text: '分钟' } },
y1: { position: 'right', title: { display: true, text: 'km' }, grid: { drawOnChartArea: false } }
}
}
})
const sCtx = el('chartGrafanaSleepStages').getContext('2d')
state.charts.grafanaSleep = new Chart(sCtx, {
type: 'bar',
data: {
labels,
datasets: [
{ label: 'REM', data: sleepRem, backgroundColor: '#60a5fa', stack: 'stack0' },
{ label: '核心', data: sleepCore, backgroundColor: '#34d399', stack: 'stack0' },
{ label: '深睡', data: sleepDeep, backgroundColor: '#818cf8', stack: 'stack0' },
{ label: '清醒', data: sleepAwake, backgroundColor: '#fb923c', stack: 'stack0' }
]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 }, stacked: true }, y: { stacked: true, title: { display: true, text: '小时' } } } }
})
const hCtx = el('chartGrafanaHeart').getContext('2d')
state.charts.grafanaHeart = new Chart(hCtx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
{ label: '步行心率', data: walkingHr, borderColor: '#ec4899', backgroundColor: 'rgba(236,72,153,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: {
x: { ticks: { maxTicksLimit: 6 } },
y: { type: 'linear', position: 'left', title: { display: true, text: 'bpm' } },
y1: { type: 'linear', position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
}
}
})
const tCtx = el('chartGrafanaTypes').getContext('2d')
state.charts.grafanaTypes = new Chart(tCtx, {
type: 'doughnut',
data: {
labels: typeLabels.slice(0, 8),
datasets: [{ data: typeValues.slice(0, 8), backgroundColor: ['#38bdf8', '#f472b6', '#facc15', '#34d399', '#a78bfa', '#fb7185', '#f97316', '#22d3ee'] }]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
})
const rCtx = el('chartGrafanaRouteMap').getContext('2d')
state.charts.grafanaRoutes = new Chart(rCtx, {
type: 'scatter',
data: {
datasets: [{ label: '路线', data: routePoints, backgroundColor: '#22d3ee' }]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { title: { display: true, text: '距离 km' } },
y: { title: { display: true, text: '时长 min' } }
}
}
})
const pCtx = el('chartGrafanaSleepPct').getContext('2d')
state.charts.grafanaSleepPct = new Chart(pCtx, {
type: 'doughnut',
data: {
labels: sleepLabels,
datasets: [{ data: sleepTotals, backgroundColor: ['#60a5fa', '#34d399', '#818cf8', '#fb923c'] }]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } } }
})
} else {
drawSimpleBars(el('chartGrafanaActivity'), labels, workout)
drawSimpleBars(el('chartGrafanaSleepStages'), labels, sleepRem)
drawSimpleLine(el('chartGrafanaHeart'), labels, [restingHr, walkingHr], ['#f43f5e', '#ec4899'])
drawSimplePie(el('chartGrafanaTypes'), typeValues.slice(0, 8), ['#38bdf8', '#f472b6', '#facc15', '#34d399', '#a78bfa', '#fb7185', '#f97316', '#22d3ee'])
drawSimpleScatter(el('chartGrafanaRouteMap'), routePoints, '#22d3ee')
drawSimplePie(el('chartGrafanaSleepPct'), sleepTotals, ['#60a5fa', '#34d399', '#818cf8', '#fb923c'])
}
setText('chartGrafanaActivityMeta', `${days}天活动趋势`)
setText('chartGrafanaSleepStagesMeta', `${days}天睡眠分期`)
setText('chartGrafanaHeartMeta', `${days}天心脏恢复`)
setText('chartGrafanaTypesMeta', '高频指标分布')
setText('chartGrafanaRouteMapMeta', routePoints.length ? '路线距离/时长分布' : '暂无路线数据')
setText('chartGrafanaSleepPctMeta', '睡眠分期占比')
}
const renderDhdCharts = async (data) => {
const trends = getTrendData(data)
const { days, labels, hr, resp, restingHr, hrv, sleep, distance } = trends
destroyCharts()
const hasChart = await ensureChart()
if (hasChart) {
const vCtx = el('chartDhdVitals').getContext('2d')
state.charts.dhdVitals = new Chart(vCtx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '心率', data: hr, borderColor: '#38bdf8', backgroundColor: 'rgba(56,189,248,0.2)', tension: 0.35, pointRadius: 2 },
{ label: '呼吸率', data: resp, borderColor: '#f472b6', backgroundColor: 'rgba(244,114,182,0.2)', tension: 0.35, pointRadius: 2 }
]
},
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const rCtx = el('chartDhdRecovery').getContext('2d')
state.charts.dhdRecovery = new Chart(rCtx, {
type: 'line',
data: {
labels,
datasets: [
{ label: '静息心率', data: restingHr, borderColor: '#f43f5e', backgroundColor: 'rgba(244,63,94,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y' },
{ label: 'HRV', data: hrv, borderColor: '#a855f7', backgroundColor: 'rgba(168,85,247,0.2)', tension: 0.35, pointRadius: 2, yAxisID: 'y1' }
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { position: 'bottom' } },
scales: {
x: { ticks: { maxTicksLimit: 6 } },
y: { title: { display: true, text: 'bpm' } },
y1: { position: 'right', title: { display: true, text: 'ms' }, grid: { drawOnChartArea: false } }
}
}
})
const sCtx = el('chartDhdSleep').getContext('2d')
state.charts.dhdSleep = new Chart(sCtx, {
type: 'line',
data: { labels, datasets: [{ label: '睡眠时长', data: sleep, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.2)', tension: 0.35, pointRadius: 2 }] },
options: { responsive: true, plugins: { legend: { position: 'bottom' } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
const dCtx = el('chartDhdDistance').getContext('2d')
state.charts.dhdDistance = new Chart(dCtx, {
type: 'bar',
data: { labels, datasets: [{ label: '公里', data: distance, backgroundColor: '#22d3ee' }] },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksLimit: 6 } } } }
})
} else {
drawSimpleLine(el('chartDhdVitals'), labels, [hr, resp], ['#38bdf8', '#f472b6'])
drawSimpleLine(el('chartDhdRecovery'), labels, [restingHr, hrv], ['#f43f5e', '#a855f7'])
drawSimpleLine(el('chartDhdSleep'), labels, [sleep], ['#60a5fa'])
drawSimpleBars(el('chartDhdDistance'), labels, distance)
}
setText('chartDhdVitalsMeta', `${days}天心肺趋势`)
setText('chartDhdRecoveryMeta', `${days}天恢复指标`)
setText('chartDhdSleepMeta', `${days}天睡眠时长`)
setText('chartDhdDistanceMeta', `${days}天步行距离`)
}
const renderActiveTab = async () => {
if (!state.lastData) return
if (state.activeTab === 'grafana') {
await renderGrafanaCharts(state.lastData)
return
}
if (state.activeTab === 'dhd') {
await renderDhdCharts(state.lastData)
return
}
if (state.activeTab === 'hdd') {
await renderHddCharts(state.lastData)
return
}
if (state.activeTab === 'aha') {
await renderAhaCharts(state.lastData)
return
}
await renderDefaultCharts(state.lastData)
}
const setupTabs = () => {
const tabs = Array.from(document.querySelectorAll('.tab-btn'))
const pages = Array.from(document.querySelectorAll('.tab-page'))
if (!tabs.length || !pages.length) return
const activate = (key) => {
tabs.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === key))
pages.forEach(page => page.classList.toggle('active', page.dataset.tabPage === key))
state.activeTab = key
renderActiveTab()
}
tabs.forEach(btn => btn.addEventListener('click', () => activate(btn.dataset.tab)))
const initialKey = tabs.find(btn => btn.classList.contains('active'))?.dataset.tab || tabs[0].dataset.tab
activate(initialKey)
}
const loadDashboard = async () => {
const overlay = el('loadingOverlay')
if (overlay) overlay.classList.add('visible')
try {
const days = Number(el('daysRange').value || 90)
const res = await fetch(`/api/apple_watch/dashboard?days=${days}`)
const json = await res.json()
if (!json.ok) throw new Error(json.error || 'load_failed')
state.lastData = json.data
updateSummary(json.data)
renderHddSummary(json.data)
renderAhaSummary(json.data)
renderGrafanaSummary(json.data)
renderDhdSummary(json.data)
renderTypeList(json.data)
renderTrendList(json.data)
renderTypeList(json.data, 'hddTypeList')
renderTrendList(json.data, 'hddTrendList')
renderTypeList(json.data, 'grafanaTypeList')
renderTrendList(json.data, 'grafanaTrendList')
renderTypeList(json.data, 'dhdTypeList')
renderAnomalies(json.data)
renderDeep(json.data)
renderAnomalies(json.data, 'ahaAnomalyList')
renderDeep(json.data, 'ahaDeepList')
renderAnomalies(json.data, 'dhdAnomalyList')
await renderActiveTab()
} catch (e) {
console.error(e)
alert('加载失败: ' + e.message)
} finally {
if (overlay) overlay.classList.remove('visible')
}
}
const runImport = async () => {
const res = await fetch('/api/apple_watch/import', { method: 'POST' })
const json = await res.json()
if (!json.ok) throw new Error(json.error || 'import_failed')
}
const init = async () => {
const refresh = async () => {
setText('statLastImport', '加载中')
await loadDashboard()
}
setupTabs()
el('btnRefresh').addEventListener('click', refresh)
el('daysRange').addEventListener('change', refresh)
el('btnImport').addEventListener('click', async () => {
setText('statLastImport', '同步中')
try {
await runImport()
await loadDashboard()
} catch {
setText('statLastImport', '同步失败')
}
})
await refresh()
}
init()