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,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>