Commit 87f2ef5a authored by Andy-bubu's avatar Andy-bubu

feat: add Xinyuan pie chart palette foundation

parent f8264ec9
...@@ -10,8 +10,9 @@ ...@@ -10,8 +10,9 @@
2. `src/tailwind.css`:Tailwind、HeroUI、HeroUI Pro CSS 入口;使用 `source(none)` 和显式源码目录,排除 Umi 生成目录。 2. `src/tailwind.css`:Tailwind、HeroUI、HeroUI Pro CSS 入口;使用 `source(none)` 和显式源码目录,排除 Umi 生成目录。
3. `src/design-system/theme.css`:不可修改的权威 HeroUI 主题。 3. `src/design-system/theme.css`:不可修改的权威 HeroUI 主题。
4. `src/design-system/tokens.css`:Skill 批准的鑫元扩展 Token。 4. `src/design-system/tokens.css`:Skill 批准的鑫元扩展 Token。
5. `src/design-system/shell.css`:基准 Shell、列表、分页、KPI 和响应式几何。 5. `src/design-system/chartPalette.js`:普通图表与 Pie/Donut 独立 Token 的解析、稳定 key 哈希和最多 16 色冲突消解。
6. 页面 JSX:仅业务组合和布局,不包含私有组件外观。 6. `src/design-system/shell.css`:基准 Shell、列表、分页、KPI 和响应式几何。
7. 页面 JSX:仅业务组合和布局,不包含私有组件外观。
## 字体稳定性 ## 字体稳定性
...@@ -28,6 +29,7 @@ ...@@ -28,6 +29,7 @@
- `XinyuanModal.jsx`:HeroUI `Modal.Root > Backdrop > Container > Dialog` 的业务无关组合,集中实现 320–1216px 设计系统宽度梯度和 28px 标准内容 inset;扩展尺寸由官方 `lg` Container 加共享最大宽度实现,不向 HeroUI v3 传递无效的 `xl/4xl` size。圆角、背景、阴影和交互状态仍由 HeroUI 官方 Modal 样式负责。 - `XinyuanModal.jsx`:HeroUI `Modal.Root > Backdrop > Container > Dialog` 的业务无关组合,集中实现 320–1216px 设计系统宽度梯度和 28px 标准内容 inset;扩展尺寸由官方 `lg` Container 加共享最大宽度实现,不向 HeroUI v3 传递无效的 `xl/4xl` size。圆角、背景、阴影和交互状态仍由 HeroUI 官方 Modal 样式负责。
- `ModelMonitorShared.jsx`:业务中性的 SearchField、DataGrid、Pagination、ActionBar、状态 Chip、动态字段组合。 - `ModelMonitorShared.jsx`:业务中性的 SearchField、DataGrid、Pagination、ActionBar、状态 Chip、动态字段组合。
- `XinyuanAutocomplete.jsx`:HeroUI Autocomplete/SearchField/ListBox 组合,暴露当前 HeroUI 值契约,不兼容旧 Select props。 - `XinyuanAutocomplete.jsx`:HeroUI Autocomplete/SearchField/ListBox 组合,暴露当前 HeroUI 值契约,不兼容旧 Select props。
- `XinyuanEChart.jsx`:业务中立 ECharts 适配器;保留 ARIA、键盘焦点、Tooltip、resize/globalout 和页面事件。只有 `type: 'pie'` 的 series 会被强制映射到 `--rc-pie-chart-*`,Bar/Line/Ranking 仍走普通 `--rc-chart-*`。
- 页面:直接组合 HeroUI/HeroUI Pro,不通过 Catalyst、Headless UI 或旧 primitive wrapper。 - 页面:直接组合 HeroUI/HeroUI Pro,不通过 Catalyst、Headless UI 或旧 primitive wrapper。
## 数据流 ## 数据流
...@@ -36,6 +38,8 @@ ...@@ -36,6 +38,8 @@
通知是本项目唯一获准新增的跨应用能力。`/notifications` 复用既有 `/notice/customer/*` API;模型分析不引入 `subjectView`、`shortTitle`、View 授权或材料中心附件适配代码。 通知是本项目唯一获准新增的跨应用能力。`/notifications` 复用既有 `/notice/customer/*` API;模型分析不引入 `subjectView`、`shortTitle`、View 授权或材料中心附件适配代码。
未来 Pie/Donut 页面必须把完整稳定分类 key 集合传给 `XinyuanEChart.pieCategoryKeys`。适配器先建立确定性颜色映射,再对当前可见数据着色,使排序、筛选、Tab 和主题变化不改变分类身份色;页面不得提供 `itemStyle.color` 私有覆盖。Pie 标签、引导线、图例、Tooltip、圆环半径、布局和点击回调仍由页面业务 option 决定,适配器不改写这些协议。
## 删除边界 ## 删除边界
`src/catalyst-ui-kit`、不可达资源预约模板及 ECharts 统计模板已从仓库移除。它们不在 `.umirc.ts` 的活动路由中,备份为 `/tmp/model-analysis-legacy-ui.iaSi6Y`。 `src/catalyst-ui-kit`、不可达资源预约模板及旧 ECharts 统计页面已从仓库移除。它们不在 `.umirc.ts` 的活动路由中,备份为 `/tmp/model-analysis-legacy-ui.iaSi6Y`。保留的 `XinyuanEChart` 是未来图表共用的业务中立基础设施,不包含路由、统计口径或测试数据。
...@@ -20,9 +20,12 @@ ...@@ -20,9 +20,12 @@
- [x] 弹窗使用 HeroUI Modal/AlertDialog;全部业务 Modal 接入共享 320–1216px 尺寸梯度,审批详情使用单一 secondary Surface - [x] 弹窗使用 HeroUI Modal/AlertDialog;全部业务 Modal 接入共享 320–1216px 尺寸梯度,审批详情使用单一 secondary Surface
- [x] 批量同步弹窗使用 HeroUI Pro Stepper/ListView、HeroUI ScrollShadow 和正确 Checkbox anatomy;移除手写灰色模型/批次分组及重复 Surface - [x] 批量同步弹窗使用 HeroUI Pro Stepper/ListView、HeroUI ScrollShadow 和正确 Checkbox anatomy;移除手写灰色模型/批次分组及重复 Surface
- [x] KPI 使用 HeroUI Pro compound API 与 16px grid gap - [x] KPI 使用 HeroUI Pro compound API 与 16px grid gap
- [x] Catalyst、Headless UI、ECharts、原生可见 select/date/file 和旧兼容包装仓库扫描为零 - [x] Catalyst、Headless UI、旧 ECharts 业务页面、原生可见 select/date/file 和旧兼容包装仓库扫描为零
- [x] 活动页面私有颜色/背景/描边/圆角/阴影类扫描为零 - [x] 活动页面私有颜色/背景/描边/圆角/阴影类扫描为零
- [x] 业务 API、参数、校验、回调、状态流和请求时机保持不变 - [x] 业务 API、参数、校验、回调、状态流和请求时机保持不变
- [x] Pie/Donut 新增独立 `--xinyuan-pie-chart-*` / `--rc-pie-chart-*` 16 色 Token;普通 Bar/Line/Ranking、KPI、品牌色和 HeroUI Primary 保持不变
- [x] 新增 `createResolvedXinyuanPieChartColorMap()` 稳定 key 映射和最多 16 色冲突消解,禁用绿色、青绿色、荧光色和彩虹色
- [x] 新增共享 `XinyuanEChart` 入口,仅对 `series.type === 'pie'` 应用专用色板并保留布局、标签、图例、Tooltip、ARIA 和事件协议
## 最终命令 ## 最终命令
...@@ -42,6 +45,7 @@ ...@@ -42,6 +45,7 @@
- [x] 未读 0/1/99/100、消息/待办、全部/未读、搜索、单条/全部已读、动态 action、站内外跳转、HTML 纯文本、Pagination、EmptyState、Toast - [x] 未读 0/1/99/100、消息/待办、全部/未读、搜索、单条/全部已读、动态 action、站内外跳转、HTML 纯文本、Pagination、EmptyState、Toast
- [x] Navbar 36px Bell/主题按钮、8px 间距、48/32/16px gutter;移动 offcanvas、Escape、无页面横向溢出 - [x] Navbar 36px Bell/主题按钮、8px 间距、48/32/16px gutter;移动 offcanvas、Escape、无页面横向溢出
- [x] 最终通知验收控制台 0 error / 0 warning - [x] 最终通知验收控制台 0 error / 0 warning
- [ ] Pie/Donut 真实数据浏览器验收:当前应用没有活动饼图/环形图或真实图表路由,未制造测试数据;首个真实调用接入时验证 2/3/4/6/6+ 分类、浅色/深色、筛选/排序稳定性及 Tooltip/Legend 一致性
- [ ] 真实通知数据端到端验收:Andy 会话调用 `/notice/customer/count` 和 `/notice/customer/query` 返回 401 - [ ] 真实通知数据端到端验收:Andy 会话调用 `/notice/customer/count` 和 `/notice/customer/query` 返回 401
浏览器结构和交互验收所用通知响应仅存在于 Playwright route 内存,不写入仓库、不作为业务数据。真实接口 401 继续交由平台权限/环境修复,前端未伪造成功状态或绕过既有请求拦截器。 浏览器结构和交互验收所用通知响应仅存在于 Playwright route 内存,不写入仓库、不作为业务数据。真实接口 401 继续交由平台权限/环境修复,前端未伪造成功状态或绕过既有请求拦截器。
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
## 基础约束 ## 基础约束
- 页面和共享组件直接使用 `@heroui/react`、`@heroui-pro/react` 及官方子路径。 - 页面和共享组件直接使用 `@heroui/react`、`@heroui-pro/react` 及官方子路径。
- 权威 `theme.css` 原样加载,SHA-256 为 `a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901`;`tokens.css` 仅提供 Skill 批准的扩展 Token。 - 权威 `theme.css` 保持 HeroUI 核心主题不变;仅追加 Skill 批准的 Pie/Donut 专用图表 Token。`tokens.css` 只提供对应项目别名和其他已批准扩展 Token。
- Inter 400/500/600 拉丁子集由 `.umirc.ts` 生成的常驻 `public/fonts/inter.css` link 加载;不得在 `tailwind.css` 中导入字体 CSS,避免 Umi HMR 替换 `umi.css` 时造成英文、数字和符号字体回退抖动。 - Inter 400/500/600 拉丁子集由 `.umirc.ts` 生成的常驻 `public/fonts/inter.css` link 加载;不得在 `tailwind.css` 中导入字体 CSS,避免 Umi HMR 替换 `umi.css` 时造成英文、数字和符号字体回退抖动。
- 统一文字层级:正文 14px/400,次要说明 13px/400,模块标题 16px/600,页面分区标题 - 统一文字层级:正文 14px/400,次要说明 13px/400,模块标题 16px/600,页面分区标题
18px/700,Navbar 标题 20px/600,表头和状态 Chip 12px/500;对应 Token 为 18px/700,Navbar 标题 20px/600,表头和状态 Chip 12px/500;对应 Token 为
...@@ -36,9 +36,18 @@ ...@@ -36,9 +36,18 @@
- 弹窗:HeroUI Modal/AlertDialog;统一通过 HeroUI 原生 `XinyuanModal` compound 组合解析尺寸梯度:`xs` 320px、`sm` 384px、`md` 480px、`lg` 640px、`xl` 736px、`2xl` 840px、`3xl` 960px、`4xl` 1088px、`5xl` 1216px。确认弹窗使用 `md`,短单列表单使用 `lg`,模型归档双列表单使用 `2xl`,内容型详情/转发/批量同步使用 `4xl`;查看态仅一个 secondary Surface。禁止将 HeroUI v3 不支持的 `xl` 至 `5xl` 直接传给 `Modal.Container`。 - 弹窗:HeroUI Modal/AlertDialog;统一通过 HeroUI 原生 `XinyuanModal` compound 组合解析尺寸梯度:`xs` 320px、`sm` 384px、`md` 480px、`lg` 640px、`xl` 736px、`2xl` 840px、`3xl` 960px、`4xl` 1088px、`5xl` 1216px。确认弹窗使用 `md`,短单列表单使用 `lg`,模型归档双列表单使用 `2xl`,内容型详情/转发/批量同步使用 `4xl`;查看态仅一个 secondary Surface。禁止将 HeroUI v3 不支持的 `xl` 至 `5xl` 直接传给 `Modal.Container`。
- 普通 Modal 保持 HeroUI 官方 24px 圆角和 Overlay shadow,统一使用 28px panel inset;Modal 内承担真实 L2 边界的 `Surface secondary` 使用基准项目一致的 `rounded-2xl`(16px),不得保留大面积直角灰色块。关闭按钮直接使用无子节点的 `Modal.CloseTrigger`,由 HeroUI 渲染原生 16px CloseIcon,禁止替换为受字体基线影响的第三方图标;关闭按钮、标题、正文和 Footer 对齐同一内容边界,标题到正文 12px、正文到 Footer 24px。批量同步使用紧凑 HeroUI Pro Stepper,年份工具区使用正确 Checkbox compound anatomy,批次选择使用单一 HeroUI Pro ListView `primary` 原生多选和 HeroUI ScrollShadow,不使用页面自制灰色分组块或重复 Surface。 - 普通 Modal 保持 HeroUI 官方 24px 圆角和 Overlay shadow,统一使用 28px panel inset;Modal 内承担真实 L2 边界的 `Surface secondary` 使用基准项目一致的 `rounded-2xl`(16px),不得保留大面积直角灰色块。关闭按钮直接使用无子节点的 `Modal.CloseTrigger`,由 HeroUI 渲染原生 16px CloseIcon,禁止替换为受字体基线影响的第三方图标;关闭按钮、标题、正文和 Footer 对齐同一内容边界,标题到正文 12px、正文到 Footer 24px。批量同步使用紧凑 HeroUI Pro Stepper,年份工具区使用正确 Checkbox compound anatomy,批次选择使用单一 HeroUI Pro ListView `primary` 原生多选和 HeroUI ScrollShadow,不使用页面自制灰色分组块或重复 Surface。
- KPI:HeroUI Pro KPI compound API;四项使用 `.rc-kpi-grid` 16px gap,标题不附加说明图标。 - KPI:HeroUI Pro KPI compound API;四项使用 `.rc-kpi-grid` 16px gap,标题不附加说明图标。
- 当前活动业务没有图表;不得为视觉验收制造图表或测试业务数据。未来增加时只使用 HeroUI Pro chart 与 ChartTooltip。 - 当前活动业务没有图表;不得为视觉验收制造图表或测试业务数据。未来图表由 HeroUI/HeroUI Pro Card 承载,使用共享 `XinyuanEChart` 适配器渲染,不在页面创建私有主题,也不新增 Recharts 调用点。
- 通知中心使用 HeroUI Tabs、SearchField、Card、Avatar、Chip、Pagination、Toast 和 HeroUI Pro EmptyState;一级模块桌面间距 32px、移动端 24px,工具栏间距 16px;页面不设置组件背景、颜色、描边、圆角、阴影或状态皮肤。 - 通知中心使用 HeroUI Tabs、SearchField、Card、Avatar、Chip、Pagination、Toast 和 HeroUI Pro EmptyState;一级模块桌面间距 32px、移动端 24px,工具栏间距 16px;页面不设置组件背景、颜色、描边、圆角、阴影或状态皮肤。
## Pie / Donut 专用色板
- Bar、Line、Ranking 和其他普通图表继续使用既有 `--xinyuan-chart-1..16`、`--rc-chart-1..16` 及 `--rc-chart-primary/risk/warning/positive/comparison/neutral`,这些值和语义映射不因 Pie/Donut 改变。
- Pie/Donut 只使用 `--xinyuan-pie-chart-1..16`,项目别名为 `--rc-pie-chart-1..16`。前六色固定为 `#DA241A`、`#3F5F86`、`#8A668F`、`#C28A3D`、`#8A6253`、`#667080`;扩展色保持低饱和灰蓝、灰紫、暖棕、暖灰和石板灰。
- 饼图分类色禁止绿色、青绿色、荧光色和彩虹色,也不得复用 HeroUI `success`、`danger`、`warning`、`foreground` 或 `muted` 状态色。
- `createResolvedXinyuanPieChartColorMap()` 对完整分类 key 去重、排序、哈希并消解冲突,同一图最多 16 个分类不重复。`XinyuanEChart` 从专用 Token 解析具体颜色,覆盖 Pie series 与数据项中的旧颜色,但不改变 radius、center、label、labelLine、labelLayout、legend、tooltip 或事件回调。
- 未来新增 Pie/Donut 必须直接使用共享 `XinyuanEChart`,并通过 `pieCategoryKeys` 传入筛选、排序、分页或 Tab 切换前的完整稳定分类 key 集合;浅色/深色主题共用同一色板,因此分类身份色不随数据顺序、筛选或主题变化。
- 专用色板只匹配 ECharts `series.type === 'pie'`,包括实心饼图和通过 `radius` 表示的环形图;不影响页面品牌主色、HeroUI Primary、KPI 或其他图表。
## 跨应用功能范围 ## 跨应用功能范围
- 当前应用显示名为“模型分析”,不属于问题整改、审计项目管理、警示案例库、廉洁从业管理、廉洁风险防控、党风廉政建设六个完整功能应用。 - 当前应用显示名为“模型分析”,不属于问题整改、审计项目管理、警示案例库、廉洁从业管理、廉洁风险防控、党风廉政建设六个完整功能应用。
......
...@@ -70,11 +70,27 @@ ...@@ -70,11 +70,27 @@
- Navbar 通知与主题按钮复用批准的共享动作组:36x36px、8px 间距、HeroUI 中性 Surface;移动端 Sidebar Dialog 通过 HeroUI Pro `Sheet.Heading` 获得可访问名称,复验控制台无警告。 - Navbar 通知与主题按钮复用批准的共享动作组:36x36px、8px 间距、HeroUI 中性 Surface;移动端 Sidebar Dialog 通过 HeroUI Pro `Sheet.Heading` 获得可访问名称,复验控制台无警告。
- 当前业务需求没有活动图表;旧 ECharts 页面已移除,因此没有伪造图表或业务数据。 - 当前业务需求没有活动图表;旧 ECharts 页面已移除,因此没有伪造图表或业务数据。
## Pie / Donut 专项审计(2026-09-08)
修改前执行 Pie/Donut、颜色 Token、ECharts 和页面颜色覆盖扫描,活动 `src` 中未发现 `PieChart`、`type: 'pie'`、donut/doughnut、页面级饼图 `itemStyle.color` 或共享 ECharts 适配器调用。
| 页面/路由 | 图表名称 | 原颜色来源 | 目标 Token | 页面级覆盖 | 共享适配器 |
| --- | --- | --- | --- | --- | --- |
| 无 | 当前应用没有活动 Pie/Donut | 不适用 | `--rc-pie-chart-1..16` | 无 | 已预置 `XinyuanEChart` |
- `theme.css` 仅新增 `--xinyuan-pie-chart-1..16`,`tokens.css` 仅新增 `--rc-pie-chart-1..16` 别名;既有普通图表、语义图表、品牌、HeroUI Primary、KPI 和 Surface Token 未修改。
- 前六个专用颜色为 `#DA241A`、`#3F5F86`、`#8A668F`、`#C28A3D`、`#8A6253`、`#667080`,后十色延续低饱和灰蓝、灰紫、暖棕、暖灰和石板灰,不包含绿色、青绿色、荧光色或彩虹色。
- 新增 `chartPalette.js`,通过排序后的稳定业务 key、哈希首选位和顺序冲突消解分配专用色;完整 key 集合不变时,排序和可见数据顺序不会改变颜色,16 个以内不重复。
- 新增业务中立 `XinyuanEChart`。它只对 `series.type === 'pie'` 覆盖颜色,Pie/Donut 的页面旧颜色无法越过适配器;Bar、Line、Ranking 和其他 series 原样保留。页面未来应传入完整 `pieCategoryKeys` 保障跨筛选和 Tab 的身份稳定。
- 当前没有真实 Pie/Donut 路由或运行数据,因此未制造 2/3/4/6/6+ 分类及 Pie/Donut 浏览器 fixture。专项浏览器数据态验收不适用,待首个真实图表接入时按设计规范补验。
- 专项验证:`npm run build`、`git diff --check` 和共享适配器 esbuild bundle 均通过;16 个稳定分类的映射测试得到 `unique=16`,反转输入顺序后映射不变。`npm ls` 确认 HeroUI Pro `1.0.0-beta.8`、HeroUI/Styles `3.2.2`、React Aria Components `1.20.0`、React Aria Utils `3.34.1` 继续使用当前仓库正式依赖,package/lock 未发生变化。
- 鑫元 `audit_project.sh` 退出码 0。其 `theme.css` 字节比较产生预期 warning:本专项按明确范围只追加 Pie/Donut Token,没有用 skill 快照覆盖目标项目既有普通图表色板;普通 `--xinyuan-chart-*` 和 `--rc-chart-*` 已与修改前 `HEAD` 逐项对比一致。
- 禁色扫描中的 `teal` / `emerald` 命中仅属于修改前已有的 KPI tone 名称和业务状态 Chip,不是 Pie/Donut Token、图表数据项颜色或页面级图表覆盖;专用 16 色值无禁用颜色。
## 主题 ## 主题
- `src/design-system/theme.css` 与 Skill 权威文件逐字节一致。 - `src/design-system/theme.css` 的 HeroUI 核心主题及既有普通图表 Token 未改动,仅追加 Pie/Donut 专用色板。
- SHA-256:`a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901` - `tokens.css` 的既有别名未改动,仅追加 `--rc-pie-chart-1..16`。
- `tokens.css` 与 Skill 副本逐字节一致。
- Shell gutter:桌面 48px、平板 32px、移动 16px,Navbar 与页面对称共用。 - Shell gutter:桌面 48px、平板 32px、移动 16px,Navbar 与页面对称共用。
## 浏览器证据 ## 浏览器证据
......
import {
createResolvedXinyuanPieChartColorMap,
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const applyTheme = (item) => ({
...item,
axisLabel: {
color: theme.axis,
fontFamily: theme.fontFamily,
fontSize: 12,
...item?.axisLabel,
},
axisLine: {
...item?.axisLine,
lineStyle: { color: theme.grid, ...item?.axisLine?.lineStyle },
},
axisTick: {
...item?.axisTick,
lineStyle: { color: theme.grid, ...item?.axisTick?.lineStyle },
},
splitLine: {
...item?.splitLine,
lineStyle: { color: theme.grid, ...item?.splitLine?.lineStyle },
},
});
return Array.isArray(axis) ? axis.map(applyTheme) : applyTheme(axis);
}
function forceItemStyle(data, itemStyle) {
if (!Array.isArray(data)) return data;
return data.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
return { ...item, itemStyle: { ...item.itemStyle, ...itemStyle } };
});
}
function readLegendCategoryKeys(legend) {
const legends = Array.isArray(legend) ? legend : legend ? [legend] : [];
return legends.flatMap((item) => (
Array.isArray(item?.data)
? item.data.map((entry) => (typeof entry === 'object' ? entry?.name : entry))
: []
));
}
export default function XinyuanEChart({
ariaLabel,
className = '',
height = 320,
onEvents,
option,
pieCategoryKeys = [],
}) {
const chartRef = useRef(null);
const hideTooltip = useCallback(() => {
chartRef.current?.getEchartsInstance?.().dispatchAction({ type: 'hideTip' });
}, []);
useEffect(() => {
window.addEventListener('resize', hideTooltip);
return () => window.removeEventListener('resize', hideTooltip);
}, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const sourceSeries = Array.isArray(option?.series) ? option.series : [option?.series];
const completePieCategoryKeys = [
...pieCategoryKeys,
...readLegendCategoryKeys(option?.legend),
...sourceSeries.flatMap((series) => (
series?.type === 'pie' && Array.isArray(series.data)
? series.data.map((item) => item?.name)
: []
)),
];
const pieColorMap = createResolvedXinyuanPieChartColorMap(completePieCategoryKeys);
const applyPieTheme = (series) => {
if (series?.type !== 'pie') return series;
const seriesItemStyle = { ...series.itemStyle };
delete seriesItemStyle.color;
const pieData = Array.isArray(series.data)
? series.data.map((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
const key = String(item.name ?? '').trim();
return {
...item,
itemStyle: {
...item.itemStyle,
color: pieColorMap.get(key)
|| theme.piePalette[index % theme.piePalette.length],
},
};
})
: series.data;
return {
...series,
color: theme.piePalette,
itemStyle: {
...seriesItemStyle,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: theme.sectorGapWidth,
},
data: forceItemStyle(pieData, {
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: theme.sectorGapWidth,
}),
padAngle: 1,
};
};
const themedSeries = Array.isArray(option?.series)
? option.series.map(applyPieTheme)
: applyPieTheme(option?.series);
return {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: { enabled: true, decal: { show: false }, ...option?.aria },
color: option?.color || theme.palette,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
...option?.textStyle,
},
...option,
series: themedSeries,
legend: Array.isArray(option?.legend)
? option.legend.map((legend) => ({
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...legend?.textStyle,
},
}))
: option?.legend ? {
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...option.legend,
textStyle: {
color: theme.label,
fontFamily: theme.fontFamily,
fontSize: 12,
lineHeight: 16,
...option.legend?.textStyle,
},
} : undefined,
tooltip: {
appendToBody: true,
backgroundColor: theme.tooltipBackground,
borderColor: theme.tooltipBorder,
borderWidth: 1,
className: 'rc-echarts-tooltip',
confine: true,
enterable: false,
extraCssText: `border-radius: ${theme.tooltipRadius}; box-shadow: ${theme.tooltipShadow};`,
padding: [10, 12],
renderMode: 'html',
...option?.tooltip,
textStyle: {
color: theme.foreground,
fontFamily: theme.fontFamily,
fontSize: 12,
...option?.tooltip?.textStyle,
},
},
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option, pieCategoryKeys]);
return (
<div
aria-label={ariaLabel}
className={`rc-chart-interaction w-full min-w-0 ${className}`.trim()}
role="img"
tabIndex={0}
>
<ReactECharts
lazyUpdate
notMerge
onEvents={{ globalout: hideTooltip, ...onEvents }}
option={themedOption}
opts={{ renderer: 'canvas' }}
ref={chartRef}
style={{ height, width: '100%' }}
/>
</div>
);
}
export const XINYUAN_CHART_PALETTE = Object.freeze(
Array.from({ length: 16 }, (_, index) => `var(--rc-chart-${index + 1})`),
);
export const XINYUAN_PIE_CHART_PALETTE = Object.freeze(
Array.from({ length: 16 }, (_, index) => `var(--rc-pie-chart-${index + 1})`),
);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
primary: 'var(--rc-chart-primary)',
risk: 'var(--rc-chart-risk)',
warning: 'var(--rc-chart-warning)',
positive: 'var(--rc-chart-positive)',
comparison: 'var(--rc-chart-comparison)',
neutral: 'var(--rc-chart-neutral)',
});
function normalizeChartKey(key) {
return String(key ?? '').trim();
}
function hashChartKey(key) {
return Array.from(key).reduce(
(value, character) => ((value * 31) + character.codePointAt(0)) >>> 0,
0,
);
}
export function getXinyuanChartColor(key, fallbackIndex = 0) {
const stableKey = normalizeChartKey(key);
if (!stableKey) {
return XINYUAN_CHART_PALETTE[fallbackIndex % XINYUAN_CHART_PALETTE.length];
}
return XINYUAN_CHART_PALETTE[hashChartKey(stableKey) % XINYUAN_CHART_PALETTE.length];
}
export function getXinyuanPieChartColor(index = 0) {
return XINYUAN_PIE_CHART_PALETTE[
Math.max(0, Number(index) || 0) % XINYUAN_PIE_CHART_PALETTE.length
];
}
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))].sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
if (keyIndex < XINYUAN_CHART_PALETTE.length) {
for (let offset = 0; offset < XINYUAN_CHART_PALETTE.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % XINYUAN_CHART_PALETTE.length;
if (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
usedIndexes.add(candidateIndex);
break;
}
}
}
colorMap.set(key, XINYUAN_CHART_PALETTE[paletteIndex]);
});
return colorMap;
}
function readRootToken(tokenName, fallback = '') {
if (typeof window === 'undefined' || typeof document === 'undefined') return fallback;
return window.getComputedStyle(document.documentElement)
.getPropertyValue(tokenName)
.trim() || fallback;
}
const concreteColorCache = new Map();
function convertOklchToRgb(value) {
const match = String(value).trim().match(
/^oklch\(\s*([\d.]+)%?\s+([\d.]+)%?\s+([\d.-]+)(?:deg)?\s*\)$/i,
);
if (!match) return value;
const lightnessValue = Number(match[1]);
const chromaValue = Number(match[2]);
const lightness = lightnessValue > 1 ? lightnessValue / 100 : lightnessValue;
const chroma = chromaValue > 1 ? chromaValue / 100 : chromaValue;
const hue = (Number(match[3]) * Math.PI) / 180;
const a = chroma * Math.cos(hue);
const b = chroma * Math.sin(hue);
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const channels = [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
].map((channel) => {
const clamped = Math.max(0, Math.min(1, channel));
const encoded = clamped <= 0.0031308
? 12.92 * clamped
: 1.055 * (clamped ** (1 / 2.4)) - 0.055;
return Math.round(encoded * 255);
});
return `rgb(${channels.join(', ')})`;
}
function readConcreteColorToken(tokenName, fallback = '') {
const raw = readRootToken(tokenName, fallback);
const cached = concreteColorCache.get(tokenName);
if (cached?.raw === raw) return cached.value;
if (typeof document === 'undefined' || !raw) return raw || fallback;
const probe = document.createElement('span');
probe.style.color = raw;
if (!probe.style.color) return fallback;
probe.style.position = 'absolute';
probe.style.pointerEvents = 'none';
document.body?.appendChild(probe);
const resolved = convertOklchToRgb(window.getComputedStyle(probe).color || fallback);
probe.remove();
concreteColorCache.set(tokenName, { raw, value: resolved });
return resolved;
}
export function resolveXinyuanChartColor(color, fallback = '') {
const match = String(color || '').trim().match(/^var\((--[^)]+)\)$/);
return match
? readConcreteColorToken(match[1], fallback)
: String(color || fallback);
}
export function createResolvedXinyuanChartColorMap(keys = []) {
return new Map(
Array.from(createXinyuanChartColorMap(keys)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function createXinyuanPieChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))].sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key) => {
const preferredIndex = hashChartKey(key) % XINYUAN_PIE_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
for (let offset = 0; offset < XINYUAN_PIE_CHART_PALETTE.length; offset += 1) {
const candidate = (preferredIndex + offset) % XINYUAN_PIE_CHART_PALETTE.length;
if (!usedIndexes.has(candidate)) {
paletteIndex = candidate;
usedIndexes.add(candidate);
break;
}
}
colorMap.set(key, getXinyuanPieChartColor(paletteIndex));
});
return colorMap;
}
export function createResolvedXinyuanPieChartColorMap(keys = []) {
return new Map(
Array.from(createXinyuanPieChartColorMap(keys)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function getXinyuanEChartsTheme() {
const documentFont = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
axis: readRootToken('--xinyuan-chart-axis', '#737373'),
fontFamily: documentFont || 'Inter, sans-serif',
foreground: readConcreteColorToken(
'--foreground',
readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
),
grid: readRootToken('--xinyuan-chart-grid', '#e8e8e8'),
label: readRootToken('--xinyuan-chart-label', '#737373'),
palette: Array.from({ length: 16 }, (_, index) => (
readRootToken(`--xinyuan-chart-${index + 1}`, '#667085')
)),
piePalette: Array.from({ length: 16 }, (_, index) => (
readConcreteColorToken(`--rc-pie-chart-${index + 1}`, '#667080')
)),
sectorGap: readConcreteColorToken('--xinyuan-chart-sector-gap', '#ffffff'),
sectorGapWidth: Number.parseFloat(
readRootToken('--xinyuan-chart-sector-gap-width', '2'),
) || 2,
sectorRadius: Number.parseFloat(
readRootToken('--xinyuan-chart-sector-radius', '10'),
) || 10,
tooltipRadius: readRootToken('--xinyuan-chart-tooltip-radius', '0.75rem'),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
};
}
...@@ -106,6 +106,23 @@ ...@@ -106,6 +106,23 @@
--xinyuan-chart-14: #8A6F63; --xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174; --xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #B0A5AD; --xinyuan-chart-16: #B0A5AD;
/* Pie / donut only. Keep this independent from the categorical palette above. */
--xinyuan-pie-chart-1: #DA241A;
--xinyuan-pie-chart-2: #3F5F86;
--xinyuan-pie-chart-3: #8A668F;
--xinyuan-pie-chart-4: #C28A3D;
--xinyuan-pie-chart-5: #8A6253;
--xinyuan-pie-chart-6: #667080;
--xinyuan-pie-chart-7: #74879A;
--xinyuan-pie-chart-8: #9A8174;
--xinyuan-pie-chart-9: #6F6674;
--xinyuan-pie-chart-10: #B87958;
--xinyuan-pie-chart-11: #61758F;
--xinyuan-pie-chart-12: #807783;
--xinyuan-pie-chart-13: #B0A4A0;
--xinyuan-pie-chart-14: #AFA5B1;
--xinyuan-pie-chart-15: #897A73;
--xinyuan-pie-chart-16: #556B88;
--xinyuan-chart-sector-gap: var(--surface); --xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-radius: 10px; --xinyuan-chart-sector-radius: 10px;
--xinyuan-chart-grid: #e8e8e8; --xinyuan-chart-grid: #e8e8e8;
......
...@@ -168,6 +168,23 @@ ...@@ -168,6 +168,23 @@
--rc-chart-14: var(--xinyuan-chart-14); --rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15); --rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16); --rc-chart-16: var(--xinyuan-chart-16);
/* Pie / donut aliases never feed bar, line, ranking or KPI colors. */
--rc-pie-chart-1: var(--xinyuan-pie-chart-1);
--rc-pie-chart-2: var(--xinyuan-pie-chart-2);
--rc-pie-chart-3: var(--xinyuan-pie-chart-3);
--rc-pie-chart-4: var(--xinyuan-pie-chart-4);
--rc-pie-chart-5: var(--xinyuan-pie-chart-5);
--rc-pie-chart-6: var(--xinyuan-pie-chart-6);
--rc-pie-chart-7: var(--xinyuan-pie-chart-7);
--rc-pie-chart-8: var(--xinyuan-pie-chart-8);
--rc-pie-chart-9: var(--xinyuan-pie-chart-9);
--rc-pie-chart-10: var(--xinyuan-pie-chart-10);
--rc-pie-chart-11: var(--xinyuan-pie-chart-11);
--rc-pie-chart-12: var(--xinyuan-pie-chart-12);
--rc-pie-chart-13: var(--xinyuan-pie-chart-13);
--rc-pie-chart-14: var(--xinyuan-pie-chart-14);
--rc-pie-chart-15: var(--xinyuan-pie-chart-15);
--rc-pie-chart-16: var(--xinyuan-pie-chart-16);
--rc-chart-primary: var(--rc-chart-1); --rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: var(--rc-chart-1); --rc-chart-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-3); --rc-chart-warning: var(--rc-chart-3);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment