535 lines
12 KiB
Plaintext
535 lines
12 KiB
Plaintext
<template>
|
|
<view class="health-time-chart">
|
|
<canvas
|
|
canvas-id="healthTimeChart"
|
|
class="chart-canvas"
|
|
@touchstart="onTouchStart"
|
|
@touchmove="onTouchMove"
|
|
@touchend="onTouchEnd"
|
|
@tap="onTap"
|
|
></canvas>
|
|
|
|
<!-- HUD显示当前值 -->
|
|
<view v-if="showHud && crosshair.visible" class="chart-hud" :style="hudStyle">
|
|
<text class="hud-time">{{ crosshair.timeLabel }}</text>
|
|
<view v-for="metric in crosshair.metrics" :key="metric.type" class="hud-metric">
|
|
<text class="hud-label" :style="{ color: metric.color }">{{ metric.label }}:</text>
|
|
<text class="hud-value">{{ metric.value }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="uts">
|
|
import { ref, reactive, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
|
|
|
// 类型定义
|
|
type HealthMetricType = 'heart_rate' | 'spo2' | 'speed'
|
|
|
|
interface HealthDataPoint {
|
|
timestamp: number
|
|
heart_rate?: number
|
|
spo2?: number
|
|
speed?: number
|
|
}
|
|
|
|
interface MetricConfig {
|
|
type: HealthMetricType
|
|
label: string
|
|
color: string
|
|
enabled: boolean
|
|
min?: number
|
|
max?: number
|
|
}
|
|
|
|
interface TimeAxisConfig {
|
|
format: 'ss' | 'mm:ss' // 时间显示格式
|
|
interval: 1 | 5 // 点间隔(秒)
|
|
}
|
|
|
|
interface ViewportState {
|
|
scaleX: number // 每个数据点的像素宽度
|
|
scrollX: number // 水平滚动偏移
|
|
minScaleX: number
|
|
maxScaleX: number
|
|
}
|
|
|
|
// Props
|
|
interface Props {
|
|
data: HealthDataPoint[]
|
|
metrics: MetricConfig[]
|
|
timeConfig: TimeAxisConfig
|
|
width: number
|
|
height: number
|
|
showHud?: boolean
|
|
autoScroll?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
showHud: true,
|
|
autoScroll: true
|
|
})
|
|
|
|
// Emits
|
|
const emit = defineEmits<{
|
|
'crosshair-change': [data: { index: number; data: HealthDataPoint; metrics: any[] }]
|
|
'viewport-change': [viewport: ViewportState]
|
|
}>()
|
|
|
|
// 响应式状态
|
|
const canvasRef = ref<any>(null)
|
|
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
|
const dpr = ref(1)
|
|
|
|
const viewport = reactive<ViewportState>({
|
|
scaleX: 20, // 每个点20像素宽
|
|
scrollX: 0,
|
|
minScaleX: 5,
|
|
maxScaleX: 100
|
|
})
|
|
|
|
const crosshair = reactive({
|
|
visible: false,
|
|
index: -1,
|
|
data: null as HealthDataPoint | null,
|
|
timeLabel: '',
|
|
metrics: [] as any[]
|
|
})
|
|
|
|
const axis = reactive({
|
|
left: 60,
|
|
right: 20,
|
|
top: 20,
|
|
bottom: 40
|
|
})
|
|
|
|
// HUD位置
|
|
const hudPos = reactive({ x: 8, y: 8 })
|
|
|
|
// 触摸状态
|
|
const pointer = reactive({
|
|
pressed: false,
|
|
x: 0,
|
|
y: 0,
|
|
lastX: 0,
|
|
lastY: 0
|
|
})
|
|
|
|
// 计算属性
|
|
const chartWidth = computed(() => props.width - axis.left - axis.right)
|
|
const chartHeight = computed(() => props.height - axis.top - axis.bottom)
|
|
|
|
const hudStyle = computed(() => ({
|
|
left: `${hudPos.x}px`,
|
|
top: `${hudPos.y}px`
|
|
}))
|
|
|
|
// 工具函数
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value))
|
|
}
|
|
|
|
function formatTime(seconds: number, format: 'ss' | 'mm:ss'): string {
|
|
if (format === 'ss') {
|
|
return seconds.toString().padStart(2, '0')
|
|
} else {
|
|
const mins = Math.floor(seconds / 60)
|
|
const secs = seconds % 60
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
|
}
|
|
}
|
|
|
|
function indexToX(index: number): number {
|
|
return axis.left + index * viewport.scaleX + viewport.scrollX
|
|
}
|
|
|
|
function xToIndex(x: number): number {
|
|
return Math.round((x - axis.left - viewport.scrollX) / viewport.scaleX)
|
|
}
|
|
|
|
function toY(value: number, metric: MetricConfig): number {
|
|
const range = (metric.max || 200) - (metric.min || 0)
|
|
const normalized = (value - (metric.min || 0)) / range
|
|
return axis.top + chartHeight.value * (1 - normalized)
|
|
}
|
|
|
|
// 渲染函数
|
|
function clearAll() {
|
|
if (!ctx.value) return
|
|
ctx.value.clearRect(0, 0, props.width, props.height)
|
|
}
|
|
|
|
function renderGrid() {
|
|
if (!ctx.value) return
|
|
const w = chartWidth.value
|
|
const h = chartHeight.value
|
|
|
|
ctx.value.save()
|
|
ctx.value.strokeStyle = '#E0E0E0'
|
|
ctx.value.lineWidth = 1
|
|
|
|
// 垂直网格线(时间轴)
|
|
const dataLength = props.data.length
|
|
for (let i = 0; i <= dataLength; i += 10) { // 每10个点画一条线
|
|
const x = indexToX(i)
|
|
if (x >= axis.left && x <= axis.left + w) {
|
|
ctx.value.beginPath()
|
|
ctx.value.moveTo(x, axis.top)
|
|
ctx.value.lineTo(x, axis.top + h)
|
|
ctx.value.stroke()
|
|
}
|
|
}
|
|
|
|
// 水平网格线(指标值)
|
|
const enabledMetrics = props.metrics.filter(m => m.enabled)
|
|
enabledMetrics.forEach(metric => {
|
|
const range = (metric.max || 200) - (metric.min || 0)
|
|
for (let i = 0; i <= 5; i++) {
|
|
const value = (metric.min || 0) + (range * i) / 5
|
|
const y = toY(value, metric)
|
|
ctx.value.beginPath()
|
|
ctx.value.moveTo(axis.left, y)
|
|
ctx.value.lineTo(axis.left + w, y)
|
|
ctx.value.stroke()
|
|
}
|
|
})
|
|
|
|
ctx.value.restore()
|
|
}
|
|
|
|
function renderMetrics() {
|
|
if (!ctx.value || !props.data.length) return
|
|
|
|
const enabledMetrics = props.metrics.filter(m => m.enabled)
|
|
enabledMetrics.forEach(metric => {
|
|
renderMetricLine(metric)
|
|
})
|
|
}
|
|
|
|
function renderMetricLine(metric: MetricConfig) {
|
|
if (!ctx.value) return
|
|
|
|
ctx.value.save()
|
|
ctx.value.strokeStyle = metric.color
|
|
ctx.value.lineWidth = 2
|
|
ctx.value.beginPath()
|
|
|
|
let hasPoints = false
|
|
props.data.forEach((point, index) => {
|
|
const value = point[metric.type]
|
|
if (value != null) {
|
|
const x = indexToX(index)
|
|
const y = toY(value, metric)
|
|
if (!hasPoints) {
|
|
ctx.value.moveTo(x, y)
|
|
hasPoints = true
|
|
} else {
|
|
ctx.value.lineTo(x, y)
|
|
}
|
|
}
|
|
})
|
|
|
|
if (hasPoints) {
|
|
ctx.value.stroke()
|
|
}
|
|
ctx.value.restore()
|
|
}
|
|
|
|
function renderTimeAxis() {
|
|
if (!ctx.value || !props.data.length) return
|
|
|
|
const w = chartWidth.value
|
|
const dataLength = props.data.length
|
|
|
|
ctx.value.save()
|
|
ctx.value.fillStyle = '#666'
|
|
ctx.value.font = '12px sans-serif'
|
|
ctx.value.textAlign = 'center'
|
|
ctx.value.textBaseline = 'top'
|
|
|
|
// 计算可见范围
|
|
const startIndex = Math.max(0, Math.floor(-viewport.scrollX / viewport.scaleX))
|
|
const endIndex = Math.min(dataLength - 1, startIndex + Math.ceil(w / viewport.scaleX))
|
|
|
|
for (let i = startIndex; i <= endIndex; i += 10) { // 每10个点显示一个时间标签
|
|
if (i < dataLength) {
|
|
const x = indexToX(i)
|
|
const seconds = i * props.timeConfig.interval
|
|
const label = formatTime(seconds, props.timeConfig.format)
|
|
ctx.value.fillText(label, x, axis.top + chartHeight.value + 4)
|
|
}
|
|
}
|
|
|
|
ctx.value.restore()
|
|
}
|
|
|
|
function renderValueAxis() {
|
|
if (!ctx.value) return
|
|
|
|
const h = chartHeight.value
|
|
const enabledMetrics = props.metrics.filter(m => m.enabled)
|
|
|
|
ctx.value.save()
|
|
ctx.value.fillStyle = '#666'
|
|
ctx.value.font = '12px sans-serif'
|
|
ctx.value.textAlign = 'right'
|
|
ctx.value.textBaseline = 'middle'
|
|
|
|
enabledMetrics.forEach(metric => {
|
|
const range = (metric.max || 200) - (metric.min || 0)
|
|
for (let i = 0; i <= 5; i++) {
|
|
const value = (metric.min || 0) + (range * i) / 5
|
|
const y = toY(value, metric)
|
|
const label = value.toFixed(metric.type === 'speed' ? 1 : 0)
|
|
ctx.value.fillText(label, axis.left - 4, y)
|
|
}
|
|
})
|
|
|
|
ctx.value.restore()
|
|
}
|
|
|
|
function renderCrosshair() {
|
|
if (!ctx.value || !crosshair.visible || crosshair.index < 0) return
|
|
|
|
const x = indexToX(crosshair.index)
|
|
const w = chartWidth.value
|
|
const h = chartHeight.value
|
|
|
|
ctx.value.save()
|
|
ctx.value.strokeStyle = '#999'
|
|
ctx.value.setLineDash([4, 4])
|
|
ctx.value.lineWidth = 1
|
|
|
|
// 垂直线
|
|
ctx.value.beginPath()
|
|
ctx.value.moveTo(x, axis.top)
|
|
ctx.value.lineTo(x, axis.top + h)
|
|
ctx.value.stroke()
|
|
|
|
// 水平线(每个指标)
|
|
const enabledMetrics = props.metrics.filter(m => m.enabled)
|
|
enabledMetrics.forEach(metric => {
|
|
const point = props.data[crosshair.index]
|
|
if (point && point[metric.type] != null) {
|
|
const y = toY(point[metric.type]!, metric)
|
|
ctx.value.beginPath()
|
|
ctx.value.moveTo(axis.left, y)
|
|
ctx.value.lineTo(axis.left + w, y)
|
|
ctx.value.stroke()
|
|
}
|
|
})
|
|
|
|
ctx.value.restore()
|
|
}
|
|
|
|
function render() {
|
|
clearAll()
|
|
renderGrid()
|
|
renderMetrics()
|
|
renderTimeAxis()
|
|
renderValueAxis()
|
|
renderCrosshair()
|
|
}
|
|
|
|
// 事件处理
|
|
function onTouchStart(e: any) {
|
|
const touches = e.touches || []
|
|
if (touches.length > 0) {
|
|
const t = touches[0]
|
|
pointer.pressed = true
|
|
pointer.x = pointer.lastX = t.x
|
|
pointer.y = pointer.lastY = t.y
|
|
}
|
|
}
|
|
|
|
function onTouchMove(e: any) {
|
|
const touches = e.touches || []
|
|
if (!pointer.pressed || touches.length === 0) return
|
|
|
|
const t = touches[0]
|
|
const dx = t.x - pointer.lastX
|
|
pointer.lastX = t.x
|
|
pointer.lastY = t.y
|
|
|
|
// 水平滚动
|
|
viewport.scrollX += dx
|
|
|
|
// 限制滚动范围
|
|
const dataLength = props.data.length
|
|
const maxScroll = Math.max(0, dataLength * viewport.scaleX - chartWidth.value)
|
|
viewport.scrollX = clamp(viewport.scrollX, -maxScroll, 0)
|
|
|
|
// 更新十字线
|
|
const index = xToIndex(t.x)
|
|
updateCrosshair(index)
|
|
|
|
scheduleRender()
|
|
}
|
|
|
|
function onTouchEnd() {
|
|
pointer.pressed = false
|
|
}
|
|
|
|
function onTap(e: any) {
|
|
const x = e.detail?.x ?? e.x
|
|
const index = xToIndex(x)
|
|
updateCrosshair(index)
|
|
scheduleRender()
|
|
}
|
|
|
|
function updateCrosshair(index: number) {
|
|
const dataLength = props.data.length
|
|
if (dataLength === 0) return
|
|
|
|
const clampedIndex = clamp(index, 0, dataLength - 1)
|
|
const point = props.data[clampedIndex]
|
|
|
|
if (point) {
|
|
crosshair.visible = true
|
|
crosshair.index = clampedIndex
|
|
crosshair.data = point
|
|
|
|
// 格式化时间标签
|
|
const seconds = clampedIndex * props.timeConfig.interval
|
|
crosshair.timeLabel = formatTime(seconds, props.timeConfig.format)
|
|
|
|
// 收集指标值
|
|
crosshair.metrics = props.metrics
|
|
.filter(m => m.enabled)
|
|
.map(metric => {
|
|
const value = point[metric.type]
|
|
return {
|
|
type: metric.type,
|
|
label: metric.label,
|
|
color: metric.color,
|
|
value: value != null ? value.toFixed(metric.type === 'speed' ? 1 : 0) : '--'
|
|
}
|
|
})
|
|
|
|
emit('crosshair-change', {
|
|
index: clampedIndex,
|
|
data: point,
|
|
metrics: crosshair.metrics
|
|
})
|
|
}
|
|
}
|
|
|
|
// 渲染调度
|
|
let rafId: number | null = null
|
|
function scheduleRender() {
|
|
if (rafId != null) cancelAnimationFrame(rafId)
|
|
rafId = requestAnimationFrame(render)
|
|
}
|
|
|
|
// 视口管理
|
|
function followRightEdge() {
|
|
const dataLength = props.data.length
|
|
if (dataLength === 0) return
|
|
|
|
const targetScroll = -(dataLength * viewport.scaleX - chartWidth.value)
|
|
viewport.scrollX = Math.max(targetScroll, 0)
|
|
}
|
|
|
|
// 生命周期
|
|
onMounted(async () => {
|
|
await nextTick()
|
|
|
|
// 获取canvas上下文
|
|
const query = uni.createSelectorQuery()
|
|
query.select('#healthTimeChart').fields({ node: true, size: true }).exec((res: any) => {
|
|
const node = res && res[0] && res[0].node
|
|
const size = res && res[0]
|
|
|
|
if (!node) {
|
|
console.error('healthTimeChart canvas node not found')
|
|
return
|
|
}
|
|
|
|
canvasRef.value = node
|
|
dpr.value = uni.getSystemInfoSync().pixelRatio || 1
|
|
|
|
node.width = Math.floor(size.width * dpr.value)
|
|
node.height = Math.floor(size.height * dpr.value)
|
|
|
|
ctx.value = node.getContext('2d') as CanvasRenderingContext2D
|
|
if (ctx.value) {
|
|
ctx.value.scale(dpr.value, dpr.value)
|
|
}
|
|
|
|
scheduleRender()
|
|
})
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (rafId != null) {
|
|
cancelAnimationFrame(rafId)
|
|
rafId = null
|
|
}
|
|
})
|
|
|
|
// 监听数据变化
|
|
watch(() => [props.data, props.metrics, props.timeConfig], () => {
|
|
if (props.autoScroll) {
|
|
followRightEdge()
|
|
}
|
|
scheduleRender()
|
|
}, { deep: true })
|
|
|
|
// 暴露方法
|
|
defineExpose({
|
|
resetView: followRightEdge,
|
|
setScale: (scale: number) => {
|
|
viewport.scaleX = clamp(scale, viewport.minScaleX, viewport.maxScaleX)
|
|
scheduleRender()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.health-time-chart {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.chart-canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
touch-action: none;
|
|
}
|
|
|
|
.chart-hud {
|
|
position: absolute;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
color: white;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
pointer-events: none;
|
|
z-index: 100;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.hud-time {
|
|
display: block;
|
|
font-weight: bold;
|
|
margin-bottom: 4px;
|
|
color: #FFD700;
|
|
}
|
|
|
|
.hud-metric {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.hud-label {
|
|
margin-right: 8px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.hud-value {
|
|
font-weight: bold;
|
|
}
|
|
</style> |