[阶段7] 前端重做:31 项指标全部露出,配色经色盲校验

原来仪表板只有 4 张卡片和 4 张图,31 项指标里绝大多数没有出口。

信息架构重组为 7 个页面(原 5 个):
- 今日   按活动 / 心率压力 / 睡眠呼吸三组展示 16 项指标
- 趋势   15 组指标可切换,5 档时间窗口(最长一年)
- 睡眠   新增。分期堆叠图 + 夜间血氧/呼吸/压力
- 成就   新增。奖励徽章按年份分组、个人纪录、运动记录
- 建议 / 同步 / 设置 保持

配色(依据 dataviz 规范,用校验器实测而非目测):
- 采用验证过的分类色板并按既定 slot 顺序取色 —— 顺序本身就是
  色盲安全机制,不是审美选择,因此只取不循环
- 浅色 worst adjacent CVD ΔE 9.1 / 常视觉 22.9
  深色 worst adjacent CVD ΔE 8.4 / 常视觉 19.8,两档全部通过
- 浅色表面下 aqua 2.74:1、yellow 2.11:1 低于 3:1,按规范提供
  "relief":每张图都带表格视图,且图例始终与文字标签同现
- 深色不是自动反色,是同色相针对深色表面重新取阶并单独校验
- 状态色(good/warning/serious/critical)保留专用,绝不当作
  第 N 个系列色;且始终图标 + 文字同现,不靠颜色单独表意

图表规范:
- 单一 y 轴,绝不双轴;量纲不同的指标拆成不同图
- 2px 线宽、4px 圆角柱端锚定基线、堆叠段间 2px 表面色间隙、
  悬停标记 2px 表面色描边
- 两系列以上必有图例,单系列不加(标题已经点明)
- 折线 connectNulls,设备漏记的日子不把线打断
- 数值文字一律用文字色令牌,不染系列色

其他:
- 主题切换(自动/浅色/深色),选择写入 localStorage 并标在 <html>
- 导航改为顶部横向,移动端可横滑
- 表格、徽章、空状态等组件统一到设计令牌

验证方式: DOM 审计确认 2px 线宽、圆角为 A 4,4 弧、堆叠段
2px 间隙、图例数量与系列数匹配、两档主题令牌各自解析到校验过的色值。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 21:17:08 +08:00
parent cbbff61082
commit ad88ec7e41
20 changed files with 2131 additions and 767 deletions

View File

@@ -0,0 +1,277 @@
import { ReactNode, useState } from 'react';
import {
Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, Line, LineChart,
ResponsiveContainer, Tooltip, XAxis, YAxis,
} from 'recharts';
import './Chart.css';
export interface Series {
key: string;
label: string;
/** 1-based slot in the categorical palette. Assigned in order, never cycled. */
slot: 1 | 2 | 3 | 4 | 5 | 6;
unit?: string;
/** Round displayed values to this many decimals. */
decimals?: number;
}
interface ChartProps {
title: string;
subtitle?: string;
data: Array<Record<string, any>>;
series: Series[];
type: 'line' | 'bar' | 'stacked-bar' | 'area';
xKey?: string;
height?: number;
/** Y-axis label; a chart has exactly one axis — never two scales. */
unit?: string;
footer?: ReactNode;
}
const fmt = (value: any, decimals = 0) =>
value == null
? '—'
: typeof value === 'number'
? value.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
: String(value);
function TooltipBox({ active, payload, label, series }: any) {
if (!active || !payload?.length) return null;
return (
<div className="viz-tooltip">
<div className="viz-tooltip-label">{label}</div>
{payload.map((entry: any) => {
const s = series.find((x: Series) => x.key === entry.dataKey);
return (
<div key={entry.dataKey} className="viz-tooltip-row">
<span
className="viz-swatch"
style={{ background: entry.color }}
aria-hidden="true"
/>
<span className="viz-tooltip-name">{s?.label ?? entry.dataKey}</span>
<span className="viz-tooltip-value">
{fmt(entry.value, s?.decimals)}
{s?.unit ? ` ${s.unit}` : ''}
</span>
</div>
);
})}
</div>
);
}
function Chart({
title, subtitle, data, series, type, xKey = 'date', height = 240, unit, footer,
}: ChartProps) {
// A table view is the relief for series whose colour falls below 3:1 on the
// light surface, and doubles as the non-visual reading of any chart.
const [showTable, setShowTable] = useState(false);
const present = series.filter((s) => data.some((row) => row[s.key] != null));
if (present.length === 0) {
return (
<figure className="viz">
<figcaption className="viz-head">
<h4>{title}</h4>
</figcaption>
<div className="viz-empty"></div>
</figure>
);
}
const color = (s: Series) => `var(--series-${s.slot})`;
const axis = {
stroke: 'var(--border-strong)',
tick: { fill: 'var(--text-muted)', fontSize: 11 },
tickLine: false,
};
const margin = { top: 8, right: 8, bottom: 0, left: -8 };
// A legend is mandatory from two series up; a single series is named by the
// title, so a legend box would only repeat it.
const legend =
present.length > 1 ? (
<Legend
verticalAlign="top"
align="left"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: string) => {
const s = present.find((x) => x.key === value);
return <span className="viz-legend-item">{s?.label ?? value}</span>;
}}
/>
) : null;
const grid = <CartesianGrid stroke="var(--grid)" vertical={false} />;
const tip = (
<Tooltip
content={<TooltipBox series={present} />}
cursor={{ stroke: 'var(--border-strong)', strokeWidth: 1 }}
/>
);
const render = () => {
if (type === 'bar' || type === 'stacked-bar') {
const stacked = type === 'stacked-bar';
return (
<BarChart data={data} margin={margin} barCategoryGap="22%">
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} />
{tip}
{legend}
{present.map((s, i) => (
<Bar
key={s.key}
dataKey={s.key}
name={s.key}
fill={color(s)}
stackId={stacked ? 'stack' : undefined}
// 4px rounded data-end on the topmost segment only, so the shape
// reads as one bar anchored to the baseline.
radius={
stacked
? i === present.length - 1
? [4, 4, 0, 0]
: [0, 0, 0, 0]
: [4, 4, 0, 0]
}
// A 2px gap in the surface colour separates adjacent fills.
stroke="var(--surface-1)"
strokeWidth={stacked ? 2 : 0}
isAnimationActive={false}
/>
))}
</BarChart>
);
}
if (type === 'area') {
return (
<AreaChart data={data} margin={margin}>
<defs>
{present.map((s) => (
<linearGradient key={s.key} id={`fill-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color(s)} stopOpacity={0.22} />
<stop offset="100%" stopColor={color(s)} stopOpacity={0.02} />
</linearGradient>
))}
</defs>
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} />
{tip}
{legend}
{present.map((s) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.key}
stroke={color(s)}
strokeWidth={2}
fill={`url(#fill-${s.key})`}
connectNulls
isAnimationActive={false}
/>
))}
</AreaChart>
);
}
return (
<LineChart data={data} margin={margin}>
{grid}
<XAxis dataKey={xKey} {...axis} />
<YAxis {...axis} width={48} domain={['auto', 'auto']} />
{tip}
{legend}
{present.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.key}
stroke={color(s)}
strokeWidth={2}
dot={false}
// A 2px surface ring keeps overlapping markers readable.
activeDot={{ r: 5, strokeWidth: 2, stroke: 'var(--surface-1)' }}
// Days the device recorded nothing must not fragment the line.
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
);
};
return (
<figure className="viz">
<figcaption className="viz-head">
<div>
<h4>
{title}
{unit && <span className="viz-unit"> ({unit})</span>}
</h4>
{subtitle && <p className="viz-sub">{subtitle}</p>}
</div>
<button
type="button"
className="viz-toggle"
onClick={() => setShowTable((v) => !v)}
aria-pressed={showTable}
>
{showTable ? '看图表' : '看数据'}
</button>
</figcaption>
{showTable ? (
<div className="viz-table-wrap">
<table className="viz-table">
<thead>
<tr>
<th scope="col"></th>
{present.map((s) => (
<th key={s.key} scope="col">
<span
className="viz-swatch"
style={{ background: color(s) }}
aria-hidden="true"
/>
{s.label}
{s.unit ? ` (${s.unit})` : ''}
</th>
))}
</tr>
</thead>
<tbody>
{[...data].reverse().map((row, i) => (
<tr key={`${row[xKey]}-${i}`}>
<th scope="row">{row[xKey]}</th>
{present.map((s) => (
<td key={s.key}>{fmt(row[s.key], s.decimals)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<ResponsiveContainer width="100%" height={height}>
{render()}
</ResponsiveContainer>
)}
{footer && <div className="viz-foot">{footer}</div>}
</figure>
);
}
export default Chart;