Commit 552e550c authored by Andy-bubu's avatar Andy-bubu

feat: add dedicated pie chart color palette

parent e7319334
...@@ -40,6 +40,10 @@ ...@@ -40,6 +40,10 @@
- `src/design-system/theme.css` 中 `--accent`、`--focus`、`--primary` 固定为 `#DA241A`, - `src/design-system/theme.css` 中 `--accent`、`--focus`、`--primary` 固定为 `#DA241A`,
由 HeroUI 官方状态变量承接按钮、选中、Hover、Pressed 和 Focus;页面不再声明另一套 Primary 色。 由 HeroUI 官方状态变量承接按钮、选中、Hover、Pressed 和 Focus;页面不再声明另一套 Primary 色。
- 图表通过 `--rc-chart-*` 使用统一的品牌红、暖色、中性色和灰蓝色低冲突序列,禁止绿色系和随机彩虹色。 - 图表通过 `--rc-chart-*` 使用统一的品牌红、暖色、中性色和灰蓝色低冲突序列,禁止绿色系和随机彩虹色。
- Pie / Donut 是普通分类色板之外的专用例外:`XinyuanEChart` 仅对 `type: 'pie'` 使用
`--rc-pie-chart-*`,前六个颜色依次为 `#DA241A`、`#3F5F86`、`#8A668F`、`#C28A3D`、
`#8A6253`、`#667080`,按稳定分类 key 确定性分配并保证同图不重复。该色板不影响 Bar、Line、
Ranking、KPI 或 HeroUI Primary;禁止绿色、青绿色、荧光色和彩虹色。
- KPI compound API 的图标 tone 仅引用共享 `--rc-metric-tone-*`,不在页面写入 hex/OKLCH 颜色。 - KPI compound API 的图标 tone 仅引用共享 `--rc-metric-tone-*`,不在页面写入 hex/OKLCH 颜色。
## 列表与反馈 ## 列表与反馈
......
...@@ -27,7 +27,8 @@ ...@@ -27,7 +27,8 @@
- [x] `/process_monitor/:processId/:application`:导航、单一 secondary Surface、动态字段和附件入口 - [x] `/process_monitor/:processId/:application`:导航、单一 secondary Surface、动态字段和附件入口
- [x] Loading/Success/Error、空状态、权限与管理操作状态保留 - [x] Loading/Success/Error、空状态、权限与管理操作状态保留
- [x] 同步更新 `process-monitor.business-flow.md` - [x] 同步更新 `process-monitor.business-flow.md`
- [N/A] 图表:目标应用没有图表业务页面 - [x] Pie / Donut 专用 `--xinyuan-pie-chart-*` / `--rc-pie-chart-*` Token 与稳定分类适配器
- [N/A] 图表业务页面:目标应用当前没有 Pie/Donut 调用点,未虚构业务图表
## 白名单功能范围 ## 白名单功能范围
......
...@@ -17,6 +17,12 @@ ...@@ -17,6 +17,12 @@
#9A8174, #5F5A61`。 #9A8174, #5F5A61`。
- 色板按品牌红深浅、暖色、中性色、灰蓝色排序,禁止绿色、青色、荧光色和彩虹式高冲突相邻配色。 - 色板按品牌红深浅、暖色、中性色、灰蓝色排序,禁止绿色、青色、荧光色和彩虹式高冲突相邻配色。
单系列图表使用 `--rc-chart-primary`;同一指标使用同一语义 Token,分类数量增加时按稳定业务 key 分配颜色。 单系列图表使用 `--rc-chart-primary`;同一指标使用同一语义 Token,分类数量增加时按稳定业务 key 分配颜色。
- 饼图 / 环形图使用独立的 `--xinyuan-pie-chart-1` 至 `--xinyuan-pie-chart-16`(别名
`--rc-pie-chart-*`),前六个固定为 `#DA241A`、`#3F5F86`、`#8A668F`、`#C28A3D`、
`#8A6253`、`#667080`,其余为低饱和灰蓝、灰紫、暖棕和暖灰。饼图不得使用绿色、青绿色、
荧光色或彩虹色;共享适配器按稳定分类 key 排序并冲突消解,筛选、排序、Tab 和主题切换均不改变分类颜色。
- 饼图专用色板只对 ECharts `type: 'pie'`(含 donut)生效,不影响普通柱状图、折线图、排名图、
KPI 或页面主色;未来新增饼图必须通过 `src/components/XinyuanEChart.jsx` 使用该适配器。
- KPI 图标色也从同一克制色板取值,Card 主体保持中性,不以页面私有颜色覆盖共享 Token。 - KPI 图标色也从同一克制色板取值,Card 主体保持中性,不以页面私有颜色覆盖共享 Token。
## 字体与样式入口 ## 字体与样式入口
......
...@@ -40,6 +40,11 @@ ...@@ -40,6 +40,11 @@
- `theme.css` 沿用技能权威结构,并按本次鑫元品牌专项将 HeroUI 主色固定为 `#DA241A`;`tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理基准一致。 - `theme.css` 沿用技能权威结构,并按本次鑫元品牌专项将 HeroUI 主色固定为 `#DA241A`;`tokens.css`、`shell.css`、`tailwind.css` 与审计项目管理基准一致。
- 本应用无业务图表页面,图表迁移和 Tooltip 验收标记为不适用,不新增虚构图表。 - 本应用无业务图表页面,图表迁移和 Tooltip 验收标记为不适用,不新增虚构图表。
- 共享图表 Token 已专项收敛为鑫元红及低饱和暖色、中性色、灰蓝色序列;不存在绿色或彩虹式默认系列。 - 共享图表 Token 已专项收敛为鑫元红及低饱和暖色、中性色、灰蓝色序列;不存在绿色或彩虹式默认系列。
- 2026-09-08 饼图 / 环形图专项同步:新增独立 `--xinyuan-pie-chart-1..16` 与
`--rc-pie-chart-*` Token,并新增业务中立 `XinyuanEChart` 适配器。前六个颜色固定为
`#DA241A`、`#3F5F86`、`#8A668F`、`#C28A3D`、`#8A6253`、`#667080`;颜色按稳定分类 key
确定性分配并解决冲突,不随筛选、排序、Tab 或主题变化。当前 OA 无 Pie/Donut 业务调用点,
未新增虚构图表;普通 `--xinyuan-chart-*` / `--rc-chart-*` 及页面布局均未改动。
- HeroUI `accent/focus/primary` 和共享 `rc-brand-primary` 已固定为 `#DA241A`,浅色/深色模式均保持一致。 - HeroUI `accent/focus/primary` 和共享 `rc-brand-primary` 已固定为 `#DA241A`,浅色/深色模式均保持一致。
## 本次共享同步 ## 本次共享同步
......
import {
XINYUAN_PIE_CHART_PALETTE,
createResolvedXinyuanPieChartColorMap,
getXinyuanEChartsTheme,
resolveXinyuanChartColor,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
function forcePieDataColors(data, colorMap, fallbackPalette) {
if (!Array.isArray(data)) return data;
return data.map((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
const key = String(item.name ?? '').trim();
const { color: _legacyColor, ...itemStyle } = item.itemStyle || {};
return {
...item,
itemStyle: {
...itemStyle,
// Data item colors are resolved at the shared adapter boundary so a
// page-level legacy itemStyle.color cannot override the pie palette.
color: colorMap.get(key)
|| fallbackPalette[index % fallbackPalette.length],
},
};
});
}
function getSeriesList(series) {
if (Array.isArray(series)) return series;
return series ? [series] : [];
}
function buildPieColorMap(seriesList) {
const keys = seriesList
.filter((series) => series?.type === 'pie')
.flatMap((series) => (Array.isArray(series.data)
? series.data.map((item) => item?.name)
: []));
return createResolvedXinyuanPieChartColorMap(keys);
}
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 chartTheme = getXinyuanEChartsTheme();
const piePalette = XINYUAN_PIE_CHART_PALETTE.map((color, index) => (
resolveXinyuanChartColor(color, `var(--rc-pie-chart-${index + 1})`)
));
const seriesList = getSeriesList(option?.series);
const pieColorMap = buildPieColorMap(seriesList);
const themedSeries = seriesList.map((series) => {
if (series?.type !== 'pie') return series;
const { color: _legacyColor, ...seriesItemStyle } = series.itemStyle || {};
return {
...series,
color: piePalette,
itemStyle: seriesItemStyle,
data: forcePieDataColors(series.data, pieColorMap, piePalette),
};
});
const nextSeries = Array.isArray(option?.series)
? themedSeries
: themedSeries[0];
const nextOption = {
animationDuration: 320,
animationDurationUpdate: 240,
animationEasing: 'cubicOut',
aria: {
enabled: true,
decal: { show: false },
...option?.aria,
},
...option,
series: nextSeries,
};
if (option?.tooltip !== false) {
nextOption.tooltip = {
appendToBody: true,
backgroundColor: chartTheme.tooltipBackground,
borderColor: chartTheme.tooltipBorder,
borderWidth: 1,
className: 'rc-echarts-tooltip',
confine: true,
enterable: false,
extraCssText: `border-radius: ${chartTheme.tooltipRadius}; box-shadow: ${chartTheme.tooltipShadow};`,
renderMode: 'html',
...option?.tooltip,
textStyle: {
color: chartTheme.tooltipForeground,
fontFamily: chartTheme.fontFamily,
fontSize: 12,
...option?.tooltip?.textStyle,
},
};
}
if (option?.legend) {
const applyLegend = (legend) => ({
icon: 'circle',
itemHeight: 8,
itemWidth: 8,
...legend,
textStyle: {
color: chartTheme.label,
fontFamily: chartTheme.fontFamily,
fontSize: 12,
lineHeight: 16,
...legend?.textStyle,
},
});
nextOption.legend = Array.isArray(option.legend)
? option.legend.map(applyLegend)
: applyLegend(option.legend);
}
return nextOption;
}, [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>
);
}
export const XINYUAN_CHART_PALETTE = Object.freeze(
Array.from({ length: 16 }, (_, index) => `var(--rc-chart-${index + 1})`),
);
// Pie and donut sectors intentionally use a separate palette from bars, lines,
// rankings and other categorical series.
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,
);
}
function getPaletteColor(palette, index) {
return palette[Math.max(0, Number(index) || 0) % palette.length];
}
export function getXinyuanChartColor(index = 0) {
return getPaletteColor(XINYUAN_CHART_PALETTE, index);
}
export function getXinyuanPieChartColor(index = 0) {
return getPaletteColor(XINYUAN_PIE_CHART_PALETTE, index);
}
function createStableColorMap(keys, palette) {
const stableKeys = [...new Set(Array.from(keys || []).map(normalizeChartKey).filter(Boolean))]
.sort();
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key) => {
const preferredIndex = hashChartKey(key) % palette.length;
let paletteIndex = preferredIndex;
for (let offset = 0; offset < palette.length; offset += 1) {
const candidate = (preferredIndex + offset) % palette.length;
if (!usedIndexes.has(candidate)) {
paletteIndex = candidate;
usedIndexes.add(candidate);
break;
}
}
colorMap.set(key, palette[paletteIndex]);
});
return colorMap;
}
export function createXinyuanChartColorMap(keys = []) {
return createStableColorMap(keys, XINYUAN_CHART_PALETTE);
}
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 fallback || raw;
}
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\((--[^)]+)\)$/);
if (!match) return String(color || fallback);
return readConcreteColorToken(match[1], fallback || `var(${match[1]})`);
}
export function createResolvedXinyuanChartColorMap(keys = []) {
return new Map(
Array.from(createXinyuanChartColorMap(keys)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function createResolvedXinyuanPieChartColorMap(keys = []) {
return new Map(
Array.from(createStableColorMap(keys, XINYUAN_PIE_CHART_PALETTE)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
);
}
export function getXinyuanEChartsTheme() {
const fontFamily = typeof document === 'undefined'
? 'Inter, sans-serif'
: window.getComputedStyle(document.body).fontFamily;
return {
fontFamily: fontFamily || 'Inter, sans-serif',
label: readConcreteColorToken('--xinyuan-chart-label', '#737373'),
tooltipBackground: readConcreteColorToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readConcreteColorToken('--xinyuan-chart-tooltip-border', '#dedede'),
tooltipForeground: readConcreteColorToken('--xinyuan-chart-tooltip-foreground', '#222222'),
tooltipShadow: readRootToken('--xinyuan-chart-tooltip-shadow', '0 16px 40px rgb(15 23 42 / 14%)'),
tooltipRadius: readRootToken('--rc-radius-overlay', '0.75rem'),
};
}
...@@ -104,6 +104,23 @@ ...@@ -104,6 +104,23 @@
--xinyuan-chart-14: #8A6F63; --xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174; --xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #5F5A61; --xinyuan-chart-16: #5F5A61;
/* Pie / donut only: independent higher-separation palette. */
--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-grid: #e8e8e8; --xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373; --xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373; --xinyuan-chart-label: #737373;
...@@ -185,6 +202,23 @@ ...@@ -185,6 +202,23 @@
--xinyuan-chart-14: #8A6F63; --xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174; --xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #5F5A61; --xinyuan-chart-16: #5F5A61;
/* Keep pie / donut colors identical across light and dark modes. */
--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-grid: #e8e8e8; --xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373; --xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373; --xinyuan-chart-label: #737373;
......
...@@ -167,6 +167,23 @@ ...@@ -167,6 +167,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 series stay separate from bar/line categorical 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