/** * ScoreGauge — 风险总分仪表盘(task 19.3,Req 20.1 / 20.6)。 * * 同时呈现 Risk_Score 数值与其对应的 Risk_Grade 文本标签(Req 20.6): * - Risk_Grade 由本地 `classifyGrade(score)` 派生,逐字镜像领域层分级规则, * 因此仪表盘展示的等级恒等于该分值的分类结果(task 19.9 / Property 71)。 * - 等级配色经 `riskGradeColorToken(grade)` 取得稳定 Color_Token,最终取值由 * ThemeProvider 解析为 CSS 变量,不在图表层硬编码具体颜色(Req 19.6)。 * * 通过通用 `ChartContainer` 取得一致的框架(标题 / 三态 / 标签),仪表盘图形以 * 确定性 SVG 半圆弧表达,分值与等级以可见文本节点呈现(便于无障碍与测试)。 * loading / empty 三态由容器统一处理(Req 20.4 / 20.5)。 */ import type { CSSProperties } from 'react'; import { colorTokenToCssVarName, riskGradeColorToken, spacing, typography, } from '../design-system/index.js'; import type { ColorToken } from '../design-system/index.js'; import { ChartContainer } from './ChartContainer.js'; import type { ChartSpec, Label, Series } from './chart-types.js'; import { classifyGrade } from './riskGrade.js'; /* ------------------------------------------------------------------ * * 局部样式原语(取自 Design Tokens,颜色经 CSS 变量引用,与 ChartContainer 一致) * ------------------------------------------------------------------ */ /** 将 Color_Token 映射为 CSS 变量引用。 */ function colorVar(token: ColorToken): string { return `var(${colorTokenToCssVarName(token)})`; } /** 取间距标度第 `step` 档(px);越界回退为 0。 */ function space(step: number): number { return spacing[step] ?? 0; } const FONT_FAMILY = "'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif"; /** 取具名排版层级的字体样式(找不到则回退到 body 近似值)。 */ function typographyStyle(name: string): CSSProperties { const level = typography.find((t) => t.name === name); if (level === undefined) { return { fontSize: '14px', lineHeight: '22px' }; } return { fontSize: `${level.fontSize}px`, lineHeight: `${level.lineHeight}px` }; } /* ------------------------------------------------------------------ * * Risk_Score 取值域 * ------------------------------------------------------------------ */ /** Risk_Score 下界(Req:0 至 100)。 */ const SCORE_MIN = 0; /** Risk_Score 上界(Req:0 至 100)。 */ const SCORE_MAX = 100; /** 将任意输入夹取到 [0, 100] 取值域内,保证弧形渲染稳健。 */ function clampScore(score: number): number { if (Number.isNaN(score)) { return SCORE_MIN; } if (score < SCORE_MIN) { return SCORE_MIN; } if (score > SCORE_MAX) { return SCORE_MAX; } return score; } /* ------------------------------------------------------------------ * * 半圆弧几何(确定性,无外部依赖) * ------------------------------------------------------------------ */ const GAUGE_WIDTH = 220; const GAUGE_HEIGHT = 120; const GAUGE_CX = GAUGE_WIDTH / 2; const GAUGE_CY = GAUGE_HEIGHT - 10; const GAUGE_RADIUS = 90; const GAUGE_STROKE = 16; /** 极坐标 → 笛卡尔坐标(角度以度计,0°=正左,180°=正右,沿上半圆)。 */ function polar(angleDeg: number): { readonly x: number; readonly y: number } { const rad = (Math.PI * (180 - angleDeg)) / 180; return { x: GAUGE_CX + GAUGE_RADIUS * Math.cos(rad), y: GAUGE_CY - GAUGE_RADIUS * Math.sin(rad), }; } /** 构造从 `startDeg` 到 `endDeg` 的半圆弧 path 数据(上半圆,0..180°)。 */ function arcPath(startDeg: number, endDeg: number): string { const start = polar(startDeg); const end = polar(endDeg); const largeArc = endDeg - startDeg > 180 ? 1 : 0; return `M ${start.x} ${start.y} A ${GAUGE_RADIUS} ${GAUGE_RADIUS} 0 ${largeArc} 1 ${end.x} ${end.y}`; } /* ------------------------------------------------------------------ * * 仪表盘图形(SVG) * ------------------------------------------------------------------ */ function GaugeArc({ score, gradeColorToken, }: { readonly score: number; readonly gradeColorToken: ColorToken; }): JSX.Element { // 分值在 [0,100] 线性映射到半圆 [0°,180°]。 const sweep = (score - SCORE_MIN) / (SCORE_MAX - SCORE_MIN) * 180; return ( {/* 轨道:完整半圆底色。 */} {/* 进度弧:长度对应分值,配色取自 Risk_Grade 的 Color_Token。 */} {sweep > 0 ? ( ) : null} ); } /* ------------------------------------------------------------------ * * ScoreGauge * ------------------------------------------------------------------ */ /** `ScoreGauge` 组件属性。 */ export interface ScoreGaugeProps { /** Risk_Score 数值(期望 0 至 100;越界将被夹取以稳健渲染)。 */ readonly score: number; /** 数据是否正在计算/请求中(true → 由容器呈现 Loading_State,Req 20.5)。 */ readonly loading?: boolean; /** 可选标题,缺省为「风险总分」。 */ readonly title?: string; } /** * 风险总分仪表盘:同时呈现 Risk_Score 数值与其对应 Risk_Grade(Req 20.6)。 * * 等级由 `classifyGrade(score)` 派生(与领域分级规则一致),等级配色取自 * `riskGradeColorToken(grade)`。分值与等级文本均以可见 DOM 文本节点呈现, * 并附 `data-risk-score` / `data-risk-grade` 便于测试与无障碍读取。 */ export function ScoreGauge({ score, loading, title, }: ScoreGaugeProps): JSX.Element { const safeScore = clampScore(score); const grade = classifyGrade(safeScore); const gradeColorToken = riskGradeColorToken(grade); // 单一系列的仪表盘:以一个数据点承载分值,使容器进入就绪态并渲染图形。 const series: Series[] = loading === true ? [] : [ { id: 'risk-score', label: '风险总分', encoding: { colorToken: gradeColorToken, textLabel: `风险总分(${grade})`, pattern: 'solid', }, points: [{ label: `Risk_Score:${safeScore}`, value: safeScore }], }, ]; const labels: Label[] = [ { kind: 'axis', text: 'Risk_Score(0–100)' }, { kind: 'partition', text: `Risk_Grade:${grade}` }, ]; const spec: ChartSpec = { type: 'ScoreGauge', status: loading === true ? 'loading' : 'ready', series, labels, title: title ?? '风险总分', emptyMessage: '暂无可展示的风险总分', loadingMessage: '正在计算风险总分…', }; return (
{/* Risk_Score 数值(Req 20.6)。 */} {safeScore} {/* Risk_Grade 文本标签(Req 20.6)。 */} {grade}
); }