[阶段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>
This commit is contained in:
@@ -169,3 +169,36 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.viz {
|
||||
transition: box-shadow 0.2s var(--ease), border-color 0.2s var(--ease);
|
||||
animation: viz-in 0.45s var(--ease) both;
|
||||
}
|
||||
|
||||
.viz:hover {
|
||||
box-shadow: var(--shadow-lift);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
@keyframes viz-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.chart-grid > .viz:nth-child(2) { animation-delay: 60ms; }
|
||||
.chart-grid > .viz:nth-child(3) { animation-delay: 120ms; }
|
||||
.chart-grid > .viz:nth-child(4) { animation-delay: 180ms; }
|
||||
.chart-grid > .viz:nth-child(n + 5) { animation-delay: 220ms; }
|
||||
|
||||
.viz-toggle {
|
||||
transition: border-color 0.15s var(--ease), color 0.15s var(--ease),
|
||||
background 0.15s var(--ease);
|
||||
}
|
||||
|
||||
.viz-toggle:hover {
|
||||
background: var(--surface-0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.viz { animation: none; transition: none; }
|
||||
}
|
||||
|
||||
25
client/src/components/charts/Ring.css
Normal file
25
client/src/components/charts/Ring.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.ring {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ring svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.ring-fill {
|
||||
transition: stroke-dashoffset 1.1s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
stroke 0.3s ease;
|
||||
}
|
||||
|
||||
.ring-center {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
71
client/src/components/charts/Ring.tsx
Normal file
71
client/src/components/charts/Ring.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePrefersReducedMotion } from '../../lib/motion';
|
||||
import './Ring.css';
|
||||
|
||||
interface RingProps {
|
||||
/** 0–1; values above 1 are drawn as a full ring plus an overshoot mark. */
|
||||
progress: number | null;
|
||||
size?: number;
|
||||
thickness?: number;
|
||||
children?: React.ReactNode;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A goal ring.
|
||||
*
|
||||
* The track is a lighter step of the fill's own ramp rather than plain grey,
|
||||
* so the pair reads as one meter at a glance. Progress past 100% keeps the
|
||||
* ring full and adds a small cap mark instead of wrapping a second lap, which
|
||||
* would read as a much smaller number than it is.
|
||||
*/
|
||||
function Ring({ progress, size = 132, thickness = 11, children, label }: RingProps) {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
const [shown, setShown] = useState(reduced ? progress ?? 0 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress == null) return;
|
||||
if (reduced) { setShown(progress); return; }
|
||||
// One frame's delay so the browser paints the empty ring first and the
|
||||
// transition actually runs.
|
||||
const id = requestAnimationFrame(() => setShown(progress));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [progress, reduced]);
|
||||
|
||||
const radius = (size - thickness) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const clamped = Math.min(1, Math.max(0, shown));
|
||||
const offset = circumference * (1 - clamped);
|
||||
const over = (progress ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<div className="ring" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} role="img" aria-label={label}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--ring-track)"
|
||||
strokeWidth={thickness}
|
||||
/>
|
||||
<circle
|
||||
className={reduced ? undefined : 'ring-fill'}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={over ? 'var(--status-good)' : 'var(--accent)'}
|
||||
strokeWidth={thickness}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="ring-center">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Ring;
|
||||
22
client/src/components/charts/Sparkline.css
Normal file
22
client/src/components/charts/Sparkline.css
Normal file
@@ -0,0 +1,22 @@
|
||||
.spark {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.spark-empty {
|
||||
display: inline-block;
|
||||
width: 68px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Draw the line on rather than fading it in — the stroke reveal traces the
|
||||
shape the reader is about to interpret. */
|
||||
.spark-path {
|
||||
stroke-dasharray: 240;
|
||||
stroke-dashoffset: 240;
|
||||
animation: spark-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes spark-draw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
89
client/src/components/charts/Sparkline.tsx
Normal file
89
client/src/components/charts/Sparkline.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from 'react';
|
||||
import { usePrefersReducedMotion } from '../../lib/motion';
|
||||
import './Sparkline.css';
|
||||
|
||||
interface SparklineProps {
|
||||
values: Array<number | null | undefined>;
|
||||
/** Points to keep, newest last. */
|
||||
points?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny trend beside a stat.
|
||||
*
|
||||
* The line sits in a de-emphasised ink and only the latest point wears the
|
||||
* accent, so the sparkline reads as context for the number it accompanies
|
||||
* rather than competing with it. It is decorative-by-position but not
|
||||
* decorative-by-meaning, so it carries an accessible label and is hidden from
|
||||
* the tree only when one is not supplied.
|
||||
*/
|
||||
function Sparkline({ values, points = 12, width = 68, height = 22, label }: SparklineProps) {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
const recent = values.slice(-points);
|
||||
const present = recent.filter((v): v is number => v != null);
|
||||
if (present.length < 2) return null;
|
||||
|
||||
const min = Math.min(...present);
|
||||
const max = Math.max(...present);
|
||||
const span = max - min || 1;
|
||||
const step = width / Math.max(recent.length - 1, 1);
|
||||
|
||||
const coords: Array<[number, number] | null> = recent.map((v, i) =>
|
||||
v == null ? null : [i * step, height - ((v - min) / span) * (height - 4) - 2]
|
||||
);
|
||||
|
||||
// Nulls break the path rather than being interpolated: a flat segment
|
||||
// across a day the device recorded nothing would be an invented value.
|
||||
let d = '';
|
||||
let pen = false;
|
||||
for (const c of coords) {
|
||||
if (!c) { pen = false; continue; }
|
||||
d += `${pen ? 'L' : 'M'}${c[0].toFixed(1)},${c[1].toFixed(1)} `;
|
||||
pen = true;
|
||||
}
|
||||
|
||||
const last = [...coords].reverse().find((c): c is [number, number] => c != null);
|
||||
return { d: d.trim(), last, length: recent.length };
|
||||
}, [values, points, width, height]);
|
||||
|
||||
if (!geometry) return <span className="spark-empty" aria-hidden="true" />;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="spark"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role={label ? 'img' : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : true}
|
||||
>
|
||||
<path
|
||||
className={reduced ? undefined : 'spark-path'}
|
||||
d={geometry.d}
|
||||
fill="none"
|
||||
stroke="var(--spark-ink)"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{geometry.last && (
|
||||
<circle
|
||||
cx={geometry.last[0]}
|
||||
cy={geometry.last[1]}
|
||||
r={2.5}
|
||||
fill="var(--accent)"
|
||||
stroke="var(--surface-1)"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sparkline;
|
||||
@@ -4,12 +4,28 @@
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease),
|
||||
border-color 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lift);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.tile-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.35rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -20,7 +36,9 @@
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.15;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Proportional figures on purpose: tabular-nums gives every digit the width
|
||||
of a zero, which makes a large standalone number look loose. Tabular is
|
||||
reserved for columns that must align (tables, axis ticks). */
|
||||
}
|
||||
|
||||
.tile-unit {
|
||||
@@ -32,7 +50,7 @@
|
||||
|
||||
.tile-meter {
|
||||
height: 4px;
|
||||
background: var(--surface-0);
|
||||
background: var(--ring-track);
|
||||
border-radius: 999px;
|
||||
margin-top: 0.55rem;
|
||||
overflow: hidden;
|
||||
@@ -42,6 +60,7 @@
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 1s var(--ease);
|
||||
}
|
||||
|
||||
.tile-detail {
|
||||
@@ -66,10 +85,29 @@
|
||||
|
||||
.tile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Cards arrive in sequence rather than all at once, so the eye is led across
|
||||
the group instead of being hit by the whole grid at the same instant. */
|
||||
.tile-grid > * {
|
||||
animation: tile-in 0.42s var(--ease) both;
|
||||
}
|
||||
|
||||
.tile-grid > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.tile-grid > *:nth-child(2) { animation-delay: 45ms; }
|
||||
.tile-grid > *:nth-child(3) { animation-delay: 90ms; }
|
||||
.tile-grid > *:nth-child(4) { animation-delay: 135ms; }
|
||||
.tile-grid > *:nth-child(5) { animation-delay: 180ms; }
|
||||
.tile-grid > *:nth-child(6) { animation-delay: 225ms; }
|
||||
.tile-grid > *:nth-child(n + 7) { animation-delay: 270ms; }
|
||||
|
||||
@keyframes tile-in {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.tile-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -78,3 +116,16 @@
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tile,
|
||||
.tile-meter-fill {
|
||||
transition: none;
|
||||
}
|
||||
.tile:hover {
|
||||
transform: none;
|
||||
}
|
||||
.tile-grid > * {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 are deliberately below 3:1. */
|
||||
light-surface status steps sit below 3:1 by design. */
|
||||
const STATUS_ICON: Record<Status, string> = {
|
||||
good: '●',
|
||||
warning: '▲',
|
||||
@@ -22,21 +24,36 @@ interface StatTileProps {
|
||||
statusLabel?: string;
|
||||
/** 0–1; 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,
|
||||
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
|
||||
? '—'
|
||||
: typeof value === 'number'
|
||||
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
|
||||
: numeric != null
|
||||
? (animated ?? numeric).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
: value;
|
||||
|
||||
return (
|
||||
<div className="tile">
|
||||
<div className="tile-label">{label}</div>
|
||||
<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>}
|
||||
|
||||
Reference in New Issue
Block a user