Files
GarminHealthLab/client/src/components/charts/StatTile.tsx
ericwyuan 757afdc941 [阶段9] 界面打磨:hero 圆环、sparkline、骨架屏、动效
参考了 itsdrchen/Garmin-AI-Coach 与主流健康 App(Apple 活动圆环、
Oura/Whoop 的评分环、Garmin Connect 的卡内趋势)的做法。

hero 区(每个视图只有一个 hero 数字):
- 步数目标圆环,超额时转为 status-good 色并保持满环 + 端点标记,
  而不是绕第二圈 —— 绕圈会让 101% 看起来比 100% 还少
- 圆环轨道用填充色同色系的浅阶而非中性灰,两者才读作同一个量器

sparkline(12 点,卡片内嵌):
- 线用去强调色,只有最新一点用强调色,让它成为数字的背景而非对手
- null 处断开而不插值 —— 设备没记录的那天连一条平线过去等于编数据

骨架屏取代转圈:
- 形状与将要出现的内容一致,数据到位时布局不跳
- 用扫光而非闪烁,读起来像进度而不是元素在求关注

动效:
- 卡片/图表依次入场(45ms 递增),引导视线扫过而不是整屏同时砸下来
- 数字滚动到位、圆环扫过、sparkline 描线、悬停抬升、按下微缩
- 全部尊重 prefers-reduced-motion:直接跳过而非缩短时长 ——
  引起不适的是位移本身,不是它持续多久

深色改为冷调(借鉴参考项目):
- 表面从暖近黑 #1a1a19 改为冷近黑 #181b21,更像运动 App
- 换表面意味着原有校验作废,已用校验器对新表面重跑:四个系列色
  全部通过含 3:1 对比度;文字亦逐个复核(正文 14.56:1、
  次要 7.98、弱化 4.38、按钮白字 6.63)

fix(a11y): 大号独立数字不应使用 tabular-nums
- 等宽数字让每个字形都占 0 的宽度,大字号下显得松散;
  等宽只保留给需要纵向对齐的表格列与坐标轴刻度

fix: 数字滚动动画吞掉了小数位
- StatTile 内部按 decimals 格式化,而调用方已经先 round 过一次,
  距离显示成 "9 km"、久坐成 "11 小时"。改为传原值 + decimals

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-23 22:48:09 +08:00

93 lines
2.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ReactNode } from 'react';
import { useCountUp } from '../../lib/motion';
import Sparkline from './Sparkline';
import './StatTile.css';
export type Status = 'good' | 'warning' | 'serious' | 'critical';
/* Status is carried by an icon plus a label, never by colour alone — the
light-surface status steps sit below 3:1 by design. */
const STATUS_ICON: Record<Status, string> = {
good: '●',
warning: '▲',
serious: '▲',
critical: '■',
};
interface StatTileProps {
label: string;
value: number | string | null | undefined;
unit?: string;
/** Secondary line: a goal, a range, a comparison. */
detail?: ReactNode;
status?: Status;
statusLabel?: string;
/** 01; draws a goal meter under the value. */
progress?: number | null;
/** Recent history for an inline trend. */
trend?: Array<number | null | undefined>;
decimals?: number;
}
function StatTile({
label, value, unit, detail, status, statusLabel, progress, trend, decimals = 0,
}: StatTileProps) {
const numeric = typeof value === 'number' ? value : null;
const animated = useCountUp(numeric);
const display =
value == null
? '—'
: numeric != null
? (animated ?? numeric).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
})
: value;
return (
<div className="tile">
<div className="tile-head">
<span className="tile-label">{label}</span>
{trend && trend.some((v) => v != null) && (
<Sparkline values={trend} label={`${label}近期走势`} />
)}
</div>
<div className="tile-value">
{display}
{unit && value != null && <span className="tile-unit">{unit}</span>}
</div>
{progress != null && (
<div
className="tile-meter"
role="meter"
aria-valuenow={Math.round(progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${label}完成度`}
>
<div
className="tile-meter-fill"
style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
/>
</div>
)}
{(detail || status) && (
<div className="tile-detail">
{status && (
<span className={`tile-status status-${status}`}>
<span aria-hidden="true">{STATUS_ICON[status]}</span> {statusLabel}
</span>
)}
{detail}
</div>
)}
</div>
);
}
export default StatTile;