Initial commit of akmon project
This commit is contained in:
535
components/HealthTimeChart.uvue
Normal file
535
components/HealthTimeChart.uvue
Normal file
@@ -0,0 +1,535 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user