Commit 7678996e authored by Andy-bubu's avatar Andy-bubu

feat: sync pie chart palette

parent 76281186
......@@ -24,7 +24,9 @@ existing material services.
`Select`-shaped compatibility wrappers and page-local chart theme helpers are
not part of the target architecture. Enum/reference fields use the current
HeroUI Autocomplete compound model, while dashboards use HeroUI Pro KPI and the
shared `XinyuanEChart` ECharts adapter with approved chart Tokens.
shared `XinyuanEChart` ECharts adapter with approved chart Tokens. Pie/Donut
series are resolved by that adapter from the dedicated `--rc-pie-chart-*`
palette; ordinary bar/line/ranking series continue using `--rc-chart-*`.
## Dependency Source
......
......@@ -15,6 +15,7 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`.
| Shell | done | Pro Sidebar.Provider/Sidebar/Navbar; fixed dark Sidebar, light content canvas, desktop offcanvas plus accessible mobile Sheet |
| Application switcher | done | model_analysis parity; `/info -> site.group`, counts, empty-group filtering, 其他应用/全部应用 fallbacks, two-column grid |
| Dashboard | done | Pro KPI uses the approved 24px Card and 48px toned icon Surface; analytical modules follow the same radius contract |
| Pie / donut palette | done | Dedicated `--xinyuan-pie-chart-*` / `--rc-pie-chart-*` tokens and stable-key assignment are owned by `XinyuanEChart`; ordinary chart palette is unchanged |
| Material archive filters | done | HeroUI Surface, Disclosure, InputGroup, Autocomplete and DatePicker compounds |
| Material DataGrid | done | Pro DataGrid with native current-page multiple selection and fixed overflow columns |
| Upload DropZone | done | Pro DropZone preserves File selection, drag/drop, ZIP mode and title prefill |
......
......@@ -37,6 +37,15 @@ paths are not valid delivery mechanisms.
restrained brand-red, warm-neutral and slate-blue family with no green or
fluorescent colors. A single series always resolves to `#DA241A` and small
series sets use the first four brand-red steps.
- Pie and donut charts use the independent `--xinyuan-pie-chart-1` through
`--xinyuan-pie-chart-16` palette and `--rc-pie-chart-*` aliases. The first six
colors are fixed to `#DA241A`, `#3F5F86`, `#8A668F`, `#C28A3D`, `#8A6253`
and `#667080`; later entries continue with muted blue-gray, gray-purple,
warm-brown and warm-gray colors. Pie colors are assigned by stable category
key through `XinyuanEChart`, with deterministic collision resolution, so
filtering, sorting and theme changes do not change a category's color.
This exception never changes ordinary bar, line, ranking, KPI or brand colors;
green, teal-green, fluorescent and rainbow pie palettes are forbidden.
- Light identity: `xy-logo-red.png`.
- Dark identity: `xy-logo-white.png`.
- Root theme: the application shell is explicitly `.light`; the content canvas stays light while the Sidebar consumes the fixed dark split-shell tokens.
......@@ -78,6 +87,8 @@ typing alone never reloads the list.
- Files use HeroUI Pro DropZone while preserving the page-owned `File` object.
- Business lists use HeroUI Pro DataGrid native selection, fixed columns,
ActionBar and compound Pagination.
- All ECharts Pie/Donut series use the shared `XinyuanEChart` adapter; pages do
not provide pie `itemStyle.color` values or replace the dedicated palette.
- Routine success feedback is a fixed, auto-dismissing Toast.
- Modal view mode uses one secondary Surface for the complete read-only field
group; edit mode removes that Surface and renders secondary fields directly.
......
......@@ -98,6 +98,11 @@ switcher grouping gap without changing its application source or navigation.
and slate-blue sequence. Green, fluorescent and rainbow series colors were
removed; `tokens.css` only aliases `--rc-chart-*` to the authoritative chart
tokens. KPI icon tones use the same non-green family.
- Pie and donut charts are audited separately through
`--xinyuan-pie-chart-*` / `--rc-pie-chart-*`; the first six values are
`#DA241A`, `#3F5F86`, `#8A668F`, `#C28A3D`, `#8A6253`, and `#667080`.
`XinyuanEChart` assigns colors by stable category key, so filtering, sorting
and theme changes preserve identity colors without affecting ordinary charts.
- `tokens.css`, `shell.css` and the login contract in `tailwind.css` retain the
approved shared baseline structure; the local brand profile overrides only
the primary/focus and chart/KPI color Tokens described above.
......
import { getXinyuanEChartsTheme } from '@/design-system/chartPalette';
import {
createResolvedXinyuanPieChartColorMap,
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
......@@ -27,14 +30,73 @@ export default function XinyuanEChart({ ariaLabel, className = '', height = 320,
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const applyPieTheme = (series) => series?.type === 'pie'
? {
const applyPieTheme = (series) => {
if (series?.type !== 'pie') return series;
const pieColorMap = createResolvedXinyuanPieChartColorMap(
Array.isArray(series.data) ? series.data.map((item) => item?.name) : [],
);
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();
const color = pieColorMap.get(key) || theme.piePalette[index % theme.piePalette.length];
const stateNames = ['emphasis', 'blur', 'select', 'selected'];
const states = Object.fromEntries(stateNames.map((stateName) => {
const state = item[stateName];
if (!state?.itemStyle) return [stateName, state];
return [stateName, {
...state,
itemStyle: { ...state.itemStyle, color },
}];
}));
return {
...item,
...states,
itemStyle: {
...item.itemStyle,
color,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
},
};
})
: series.data;
const seriesItemStyle = Object.fromEntries(
Object.entries(series.itemStyle || {}).filter(([key]) => key !== 'color'),
);
const stateNames = ['emphasis', 'blur', 'select', 'selected'];
const states = Object.fromEntries(stateNames.map((stateName) => {
const state = series[stateName];
if (!state?.itemStyle) return [stateName, state];
return [stateName, {
...state,
itemStyle: Object.fromEntries(
Object.entries(state.itemStyle).filter(([key]) => key !== 'color'),
),
}];
}));
return {
...series,
itemStyle: { borderColor: theme.sectorGap, borderRadius: theme.sectorRadius, borderWidth: 2, ...series.itemStyle },
...states,
color: theme.piePalette,
itemStyle: {
...seriesItemStyle,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
},
data: pieData,
padAngle: series.padAngle ?? 1,
}
: series;
};
};
const series = Array.isArray(option?.series) ? option.series.map(applyPieTheme) : applyPieTheme(option?.series);
const seriesList = Array.isArray(series) ? series : [series];
const hasPieSeries = seriesList.some((entry) => entry?.type === 'pie');
const hasNonPieSeries = seriesList.some((entry) => entry && entry.type !== 'pie');
return {
animationDuration: 320,
animationDurationUpdate: 240,
......@@ -43,6 +105,10 @@ export default function XinyuanEChart({ ariaLabel, className = '', height = 320,
color: option?.color || theme.palette,
textStyle: { color: theme.label, fontFamily: theme.fontFamily, ...option?.textStyle },
...option,
// A pie-only option must not let a legacy page-level color array replace
// the dedicated Pie/Donut palette. Mixed charts keep their existing
// top-level color semantics; pie data items remain explicitly colored.
...(hasPieSeries && !hasNonPieSeries ? { color: theme.piePalette } : {}),
series,
legend: option?.legend ? {
icon: 'circle', itemHeight: 8, itemWidth: 8, ...option.legend,
......
......@@ -17,6 +17,10 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
"var(--rc-chart-16)",
]);
export const XINYUAN_PIE_CHART_PALETTE = Object.freeze(
Array.from({ length: 16 }, (_, index) => `var(--rc-pie-chart-${index + 1})`),
);
const SMALL_SERIES_PALETTE_INDEXES = Object.freeze([0, 1, 2, 3]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
......@@ -47,6 +51,12 @@ export function getXinyuanChartColor(key, fallbackIndex = 0) {
return XINYUAN_CHART_PALETTE[paletteIndex];
}
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();
......@@ -109,11 +119,21 @@ export function colorizeXinyuanChartTooltipPayload(payload = [], resolveColor) {
});
}
function readRootToken(tokenName, fallback = '') {
function readRootToken(tokenName, fallback = '', depth = 0) {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback;
}
return window.getComputedStyle(document.documentElement).getPropertyValue(tokenName).trim() || fallback;
const value = window.getComputedStyle(document.documentElement)
.getPropertyValue(tokenName)
.trim();
if (!value) return fallback;
const alias = value.match(/^var\((--[^,)]+)(?:,[^)]+)?\)$/);
if (alias && depth < 4) {
return readRootToken(alias[1], fallback, depth + 1);
}
return value;
}
export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
......@@ -123,6 +143,32 @@ export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
}));
}
export function createResolvedXinyuanPieChartColorMap(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;
}
}
const token = XINYUAN_PIE_CHART_PALETTE[paletteIndex];
const match = String(token).match(/^var\((--[^)]+)\)$/);
colorMap.set(key, match ? readRootToken(match[1], '#667080') : token);
});
return colorMap;
}
export function getXinyuanEChartsTheme() {
const fontFamily = typeof document === 'undefined'
? 'Inter, sans-serif'
......@@ -134,6 +180,7 @@ export function getXinyuanEChartsTheme() {
foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
fontFamily: fontFamily || 'Inter, sans-serif',
palette: Array.from({ length: 16 }, (_, index) => readRootToken(`--xinyuan-chart-${index + 1}`, '#667085')),
piePalette: Array.from({ length: 16 }, (_, index) => readRootToken(`--rc-pie-chart-${index + 1}`, '#667080')),
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%)'),
......
......@@ -86,6 +86,23 @@
--xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #B5A6A1;
/* Pie / donut only: keep sector colors independent from bar and line charts. */
--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-axis: #737373;
--xinyuan-chart-label: #737373;
......@@ -132,4 +149,21 @@
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 49.38);
/* Keep Pie / Donut category identity colors unchanged in dark mode. */
--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;
}
......@@ -165,6 +165,23 @@
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
/* Pie / donut series use a dedicated palette; ordinary chart tokens stay 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(--xinyuan-chart-2);
--rc-chart-warning: var(--rc-chart-6);
......
......@@ -217,7 +217,6 @@ function CategoryDistributionChart({ data, uniqueCategoryCount }) {
name: item.label,
value: item.count,
}));
const colorMap = createResolvedXinyuanChartColorMap(chartData.map((item) => item.name));
if (chartData.length === 0) {
return <ChartEmptyState title="暂无分类数据" />;
......@@ -232,16 +231,16 @@ function CategoryDistributionChart({ data, uniqueCategoryCount }) {
data: chartData.map((item) => ({
name: item.name,
value: item.value,
itemStyle: { color: colorMap.get(item.name) },
itemStyle: {},
emphasis: {
itemStyle: { color: colorMap.get(item.name), opacity: 1 },
itemStyle: { opacity: 1 },
},
blur: {
itemStyle: { color: colorMap.get(item.name), opacity: 1 },
itemStyle: { opacity: 1 },
},
})),
label: { show: true, formatter: ({ name, value }) => `{name|${name}}\n{value|${value} 份}`, rich: { name: { color: theme.label, fontSize: 12, lineHeight: 16 }, value: { color: theme.label, fontSize: 13, fontWeight: 600, lineHeight: 18 } } },
labelLine: { show: true, length: 16, length2: 18, lineStyle: { color: theme.grid } },
labelLine: { show: true, length: 16, length2: 18 },
}],
tooltip: {
trigger: "item",
......
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