Commit 10603edb authored by Andy-bubu's avatar Andy-bubu

feat: add dedicated pie chart palette

parent 3334a511
......@@ -30,6 +30,10 @@ Umi route /ai_chat/
- Ant Design Icons 可作为纯图标目录保留;不得通过它包装 UI primitive。
- 服务层、审计引擎、Excel 解析/导出和 tracking 不因 UI 迁移而重写。
### 图表适配器边界
`src/components/XinyuanEChart.jsx` 是业务中立的 ECharts 适配器。它仅对 `series.type === 'pie'`(包括 Donut)注入独立 Pie/Donut Token 和扇区间隙/圆角; Bar、Line、Ranking 及其他 series 仍走普通图表色板和既有配置。分类颜色由 `src/design-system/chartPalette.js` 基于稳定 key 排序、哈希和冲突消解后解析为具体 CSS 颜色,Tooltip、Legend、ARIA、resize 与 globalout 行为保持共享适配器的官方边界。页面只提供业务数据和 series 结构,不直接写饼图颜色常量。
## 依赖来源
- HeroUI(`@heroui/react`)和 HeroUI Pro(`@heroui-pro/react`)是项目唯一允许的 UI 组件库。
......
......@@ -101,3 +101,11 @@
- [x] 图表色板移除绿色、荧光和彩虹式高冲突组合
- [x] KPI 图标色引用同一 Chart Token,单系列默认品牌红
- [-] 当前 AI 应用没有真实业务图表调用点,未制造测试图表数据
## 饼图/环形图专项配色
- [x] 新增独立 `xinyuan-pie-chart-1..16` 与 `rc-pie-chart-1..16` Token
- [x] 前六个专用颜色与问题整改基准一致,且不含绿色/青绿色/荧光/彩虹色
- [x] 新增共享 `XinyuanEChart` Pie/Donut 适配器;页面旧 `itemStyle.color` 不得覆盖
- [x] 颜色按稳定分类 key 排序、哈希和冲突消解;普通 Bar/Line/Ranking 色板保持不变
- [~] 当前无真实 Pie/Donut 业务调用点,浏览器扇区、Legend、Tooltip 和筛选后稳定性待真实数据接入后复验
......@@ -84,3 +84,7 @@ Inter 字体文件独立存放于 `public/fonts/`,并由 `.umirc.ts` 注入稳
- 品牌主色固定为 `#DA241A`,HeroUI `--primary`、`--accent`、`--focus` 以及 hover/pressed 状态均使用鑫元红阶。
- 数据可视化统一消费 `--xinyuan-chart-1..16` 与 `--rc-chart-1..16`,按品牌红深浅、暖色、中性色、灰蓝顺序排列,不使用绿色或荧光色。
- KPI/数据卡片图标色映射到同一 `rc-chart-*` Token,单系列默认使用品牌红。
### 饼图与环形图专项
饼图和环形图(ECharts `series.type === 'pie'`)使用独立的 `--xinyuan-pie-chart-1..16` / `--rc-pie-chart-1..16` 色板;柱状图、折线图、排名图和组合图继续使用原有 `--xinyuan-chart-*` 色板,不共享这组 Token。前六个专用颜色固定为 `#DA241A`、`#3F5F86`、`#8A668F`、`#C28A3D`、`#8A6253`、 `#667080`,其余为低饱和蓝灰、灰紫、暖棕和暖灰,禁止绿色、青绿色、荧光色及彩虹色。未来 Pie/Donut 必须通过 `XinyuanEChart` 共享适配器渲染;适配器先收集完整分类 key、排序并确定性分配颜色,筛选、排序、Tab 或浅/深色主题变化不得改变同一分类的颜色,也不允许页面级 `itemStyle.color` 覆盖专用色板。
......@@ -97,6 +97,14 @@
- 当前活动范围未发现 KPI 或 PieChart/BarChart/ComposedChart 业务数据。禁止为了验收伪造 KPI/图表;若后续配置启用真实数据,应直接使用 HeroUI Pro compound API。
- 当前无页面级 Tabs、DatePicker 或可见原生 select/date 控件;不凭空增加。
### 饼图/环形图配色专项审计(2026-09-08)
- 全局扫描未发现真实 `PieChart`、`Donut` 或 `series.type = 'pie'` 调用点,因此没有受影响的业务页面、路由、统计口径或点击回调。
- 新增独立 `--xinyuan-pie-chart-*` 与 `--rc-pie-chart-*` Token,以及业务中立 `XinyuanEChart` 适配器,供未来 Pie/Donut 直接复用。
- 普通 `--xinyuan-chart-*` / `--rc-chart-*`、品牌主色、HeroUI Primary、页面布局与业务 API 均未修改。
- 前六个专用颜色为 `#DA241A`、`#3F5F86`、`#8A668F`、`#C28A3D`、`#8A6253`、`#667080`;专用色板不含绿色、青绿色、荧光色和彩虹色。
- 浏览器无法对空缺的真实图表数据执行扇区、Legend、Tooltip 像素验收;待业务出现真实 Pie/Donut 数据后,需按 2/3/4/6/16 类别、筛选排序稳定性及双主题复验。
## 原始浏览器基线
- 1440x900 目标:深色玻璃聊天工作台,左侧 316px,登录为 920x564 营销式分栏 Modal。
......
import {
createResolvedXinyuanPieChartColorMap,
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
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 mergeAxisTheme(axis, theme) {
if (!axis) return axis;
const apply = (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(apply) : apply(axis);
}
function axisType(axis) {
return Array.isArray(axis) ? axis[0]?.type : axis?.type;
}
function barRadius(option, theme) {
const horizontal =
axisType(option?.xAxis) === 'value' &&
axisType(option?.yAxis) === 'category';
return horizontal
? [0, theme.barRadius, theme.barRadius, 0]
: [theme.barRadius, theme.barRadius, 0, 0];
}
export default function XinyuanEChart({
ariaLabel,
className = '',
height = 320,
onEvents,
option,
}) {
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 applySeriesTheme = (series) => {
if (!series || series.type !== 'pie') {
if (series?.type !== 'bar') return series;
return {
...series,
itemStyle: {
...series.itemStyle,
borderRadius: barRadius(option, theme),
},
data: forceItemStyle(series.data, {
borderRadius: barRadius(option, theme),
}),
};
}
const colorMap = createResolvedXinyuanPieChartColorMap(
Array.isArray(series.data) ? series.data.map((item) => item?.name) : [],
);
const data = 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:
colorMap.get(key) ||
theme.piePalette[index % theme.piePalette.length],
},
};
})
: series.data;
return {
...series,
color: theme.piePalette,
itemStyle: {
...series.itemStyle,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: theme.sectorGapWidth,
},
data: forceItemStyle(data, {
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: theme.sectorGapWidth,
}),
padAngle: 1,
};
};
const themedSeries = Array.isArray(option?.series)
? option.series.map(applySeriesTheme)
: applySeriesTheme(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,
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.tooltipForeground,
fontFamily: theme.fontFamily,
fontSize: 12,
...option?.tooltip?.textStyle,
},
},
xAxis: mergeAxisTheme(option?.xAxis, theme),
yAxis: mergeAxisTheme(option?.yAxis, theme),
};
}, [option]);
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>
);
}
/**
* Shared Xinyuan ECharts palette helpers.
*
* Pie and donut series intentionally use a separate palette. The regular
* palette remains the source for bar, line and ranking charts.
*/
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 normalizeKey(value) {
return String(value ?? '').trim();
}
function hashKey(key) {
return Array.from(key).reduce(
(hash, character) => (hash * 31 + character.codePointAt(0)) >>> 0,
0,
);
}
export function getXinyuanChartColor(key, fallbackIndex = 0) {
const stableKey = normalizeKey(key);
if (!stableKey)
return XINYUAN_CHART_PALETTE[fallbackIndex % XINYUAN_CHART_PALETTE.length];
return XINYUAN_CHART_PALETTE[
hashKey(stableKey) % XINYUAN_CHART_PALETTE.length
];
}
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [
...new Set(keys.map(normalizeKey).filter(Boolean)),
].sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key) => {
const preferred = hashKey(key) % XINYUAN_CHART_PALETTE.length;
for (let offset = 0; offset < XINYUAN_CHART_PALETTE.length; offset += 1) {
const candidate = (preferred + offset) % XINYUAN_CHART_PALETTE.length;
if (!usedIndexes.has(candidate)) {
usedIndexes.add(candidate);
colorMap.set(key, XINYUAN_CHART_PALETTE[candidate]);
break;
}
}
});
return colorMap;
}
export function resolveXinyuanChartColor(color, fallback = '') {
return resolveCssColor(color, fallback);
}
export function createResolvedXinyuanChartColorMap(keys = []) {
return new Map(
Array.from(createXinyuanChartColorMap(keys)).map(([key, color]) => [
key,
resolveCssColor(color),
]),
);
}
export function getXinyuanPieChartColor(index = 0) {
const safeIndex = Math.max(0, Number(index) || 0);
return XINYUAN_PIE_CHART_PALETTE[
safeIndex % XINYUAN_PIE_CHART_PALETTE.length
];
}
/**
* Resolve a stable category-key map. Keys are sorted before collision
* probing, so value sorting, filtering and tab changes do not reorder colors.
*/
export function createXinyuanPieChartColorMap(keys = []) {
const stableKeys = [
...new Set(keys.map(normalizeKey).filter(Boolean)),
].sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key) => {
const preferred = hashKey(key) % XINYUAN_PIE_CHART_PALETTE.length;
let paletteIndex = preferred;
for (
let offset = 0;
offset < XINYUAN_PIE_CHART_PALETTE.length;
offset += 1
) {
const candidate = (preferred + offset) % XINYUAN_PIE_CHART_PALETTE.length;
if (!usedIndexes.has(candidate)) {
paletteIndex = candidate;
usedIndexes.add(candidate);
break;
}
}
colorMap.set(key, getXinyuanPieChartColor(paletteIndex));
});
return colorMap;
}
const concreteColorCache = new Map();
function resolveCssColor(token, fallback = '') {
const match = String(token || '')
.trim()
.match(/^var\((--[^)]+)\)$/);
if (!match || typeof document === 'undefined')
return String(token || fallback);
const raw = window
.getComputedStyle(document.documentElement)
.getPropertyValue(match[1])
.trim();
if (!raw) return fallback;
const cached = concreteColorCache.get(match[1]);
if (cached?.raw === raw) return cached.value;
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 value = window.getComputedStyle(probe).color || fallback;
probe.remove();
concreteColorCache.set(match[1], { raw, value });
return value;
}
export function createResolvedXinyuanPieChartColorMap(keys = []) {
return new Map(
Array.from(createXinyuanPieChartColorMap(keys)).map(([key, color]) => [
key,
resolveCssColor(color, '#667080'),
]),
);
}
export function getXinyuanEChartsTheme() {
const read = (name, fallback) => {
if (typeof document === 'undefined') return fallback;
return (
window
.getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim() || fallback
);
};
const fontFamily =
typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
axis: read('--xinyuan-chart-axis', '#737373'),
fontFamily: fontFamily || 'Inter, sans-serif',
grid: read('--xinyuan-chart-grid', '#e8e8e8'),
label: read('--xinyuan-chart-label', '#737373'),
palette: Array.from({ length: 16 }, (_, index) =>
read(`--xinyuan-chart-${index + 1}`, '#667080'),
),
piePalette: Array.from({ length: 16 }, (_, index) =>
resolveCssColor(`var(--xinyuan-pie-chart-${index + 1})`, '#667080'),
),
sectorGap: resolveCssColor(
read('--xinyuan-chart-sector-gap', '#fff'),
'#fff',
),
sectorRadius:
Number.parseFloat(read('--xinyuan-chart-sector-radius', '10')) || 10,
sectorGapWidth:
Number.parseFloat(read('--xinyuan-chart-sector-gap-width', '2')) || 2,
barRadius: Number.parseFloat(read('--xinyuan-chart-bar-radius', '8')) || 8,
tooltipBackground: resolveCssColor(
read('--xinyuan-chart-tooltip-background', '#fff'),
'#fff',
),
tooltipBorder: resolveCssColor(
read('--xinyuan-chart-tooltip-border', '#dedede'),
'#dedede',
),
tooltipForeground: resolveCssColor(
read('--xinyuan-chart-tooltip-foreground', '#222'),
'#222',
),
tooltipShadow: read(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
),
tooltipRadius: read('--xinyuan-chart-tooltip-radius', '0.75rem'),
};
}
......@@ -109,6 +109,24 @@
--xinyuan-chart-14: #8a6f63;
--xinyuan-chart-15: #9a8174;
--xinyuan-chart-16: #b0a5ad;
/* Pie / donut only. Keep this palette independent from bar/line colors. */
--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-radius: 10px;
--xinyuan-chart-grid: #e8e8e8;
......
......@@ -160,6 +160,24 @@
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
/* Pie / donut aliases. Ordinary chart aliases above remain unchanged. */
--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-risk: var(--rc-chart-1);
--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