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

feat: sync pie chart palette

parent 76281186
...@@ -24,7 +24,9 @@ existing material services. ...@@ -24,7 +24,9 @@ existing material services.
`Select`-shaped compatibility wrappers and page-local chart theme helpers are `Select`-shaped compatibility wrappers and page-local chart theme helpers are
not part of the target architecture. Enum/reference fields use the current not part of the target architecture. Enum/reference fields use the current
HeroUI Autocomplete compound model, while dashboards use HeroUI Pro KPI and the 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 ## Dependency Source
......
...@@ -15,6 +15,7 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`. ...@@ -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 | | 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 | | 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 | | 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 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 | | 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 | | 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. ...@@ -37,6 +37,15 @@ paths are not valid delivery mechanisms.
restrained brand-red, warm-neutral and slate-blue family with no green or restrained brand-red, warm-neutral and slate-blue family with no green or
fluorescent colors. A single series always resolves to `#DA241A` and small fluorescent colors. A single series always resolves to `#DA241A` and small
series sets use the first four brand-red steps. 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`. - Light identity: `xy-logo-red.png`.
- Dark identity: `xy-logo-white.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. - 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. ...@@ -78,6 +87,8 @@ typing alone never reloads the list.
- Files use HeroUI Pro DropZone while preserving the page-owned `File` object. - Files use HeroUI Pro DropZone while preserving the page-owned `File` object.
- Business lists use HeroUI Pro DataGrid native selection, fixed columns, - Business lists use HeroUI Pro DataGrid native selection, fixed columns,
ActionBar and compound Pagination. 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. - Routine success feedback is a fixed, auto-dismissing Toast.
- Modal view mode uses one secondary Surface for the complete read-only field - Modal view mode uses one secondary Surface for the complete read-only field
group; edit mode removes that Surface and renders secondary fields directly. 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. ...@@ -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 and slate-blue sequence. Green, fluorescent and rainbow series colors were
removed; `tokens.css` only aliases `--rc-chart-*` to the authoritative chart removed; `tokens.css` only aliases `--rc-chart-*` to the authoritative chart
tokens. KPI icon tones use the same non-green family. 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 - `tokens.css`, `shell.css` and the login contract in `tailwind.css` retain the
approved shared baseline structure; the local brand profile overrides only approved shared baseline structure; the local brand profile overrides only
the primary/focus and chart/KPI color Tokens described above. 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 ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
...@@ -27,14 +30,73 @@ export default function XinyuanEChart({ ariaLabel, className = '', height = 320, ...@@ -27,14 +30,73 @@ export default function XinyuanEChart({ ariaLabel, className = '', height = 320,
const themedOption = useMemo(() => { const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme(); const theme = getXinyuanEChartsTheme();
const applyPieTheme = (series) => series?.type === 'pie' const applyPieTheme = (series) => {
? { if (series?.type !== 'pie') return series;
...series,
itemStyle: { borderColor: theme.sectorGap, borderRadius: theme.sectorRadius, borderWidth: 2, ...series.itemStyle }, const pieColorMap = createResolvedXinyuanPieChartColorMap(
padAngle: series.padAngle ?? 1, Array.isArray(series.data) ? series.data.map((item) => item?.name) : [],
} );
: series; 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,
...states,
color: theme.piePalette,
itemStyle: {
...seriesItemStyle,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
},
data: pieData,
padAngle: series.padAngle ?? 1,
};
};
const series = Array.isArray(option?.series) ? option.series.map(applyPieTheme) : applyPieTheme(option?.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 { return {
animationDuration: 320, animationDuration: 320,
animationDurationUpdate: 240, animationDurationUpdate: 240,
...@@ -43,6 +105,10 @@ export default function XinyuanEChart({ ariaLabel, className = '', height = 320, ...@@ -43,6 +105,10 @@ export default function XinyuanEChart({ ariaLabel, className = '', height = 320,
color: option?.color || theme.palette, color: option?.color || theme.palette,
textStyle: { color: theme.label, fontFamily: theme.fontFamily, ...option?.textStyle }, textStyle: { color: theme.label, fontFamily: theme.fontFamily, ...option?.textStyle },
...option, ...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, series,
legend: option?.legend ? { legend: option?.legend ? {
icon: 'circle', itemHeight: 8, itemWidth: 8, ...option.legend, icon: 'circle', itemHeight: 8, itemWidth: 8, ...option.legend,
......
...@@ -17,6 +17,10 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([ ...@@ -17,6 +17,10 @@ export const XINYUAN_CHART_PALETTE = Object.freeze([
"var(--rc-chart-16)", "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]); const SMALL_SERIES_PALETTE_INDEXES = Object.freeze([0, 1, 2, 3]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({ export const XINYUAN_CHART_SEMANTIC = Object.freeze({
...@@ -47,6 +51,12 @@ export function getXinyuanChartColor(key, fallbackIndex = 0) { ...@@ -47,6 +51,12 @@ export function getXinyuanChartColor(key, fallbackIndex = 0) {
return XINYUAN_CHART_PALETTE[paletteIndex]; 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 = []) { export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))].sort(); const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))].sort();
const colorMap = new Map(); const colorMap = new Map();
...@@ -109,11 +119,21 @@ export function colorizeXinyuanChartTooltipPayload(payload = [], resolveColor) { ...@@ -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') { if (typeof window === 'undefined' || typeof document === 'undefined') {
return fallback; 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 = {}) { export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
...@@ -123,6 +143,32 @@ 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() { export function getXinyuanEChartsTheme() {
const fontFamily = typeof document === 'undefined' const fontFamily = typeof document === 'undefined'
? 'Inter, sans-serif' ? 'Inter, sans-serif'
...@@ -134,6 +180,7 @@ export function getXinyuanEChartsTheme() { ...@@ -134,6 +180,7 @@ export function getXinyuanEChartsTheme() {
foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'), foreground: readRootToken('--xinyuan-chart-tooltip-foreground', '#222222'),
fontFamily: fontFamily || 'Inter, sans-serif', fontFamily: fontFamily || 'Inter, sans-serif',
palette: Array.from({ length: 16 }, (_, index) => readRootToken(`--xinyuan-chart-${index + 1}`, '#667085')), 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'), tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'), tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
tooltipShadow: readRootToken('--xinyuan-chart-tooltip-shadow', '0 16px 40px rgb(15 23 42 / 14%)'), tooltipShadow: readRootToken('--xinyuan-chart-tooltip-shadow', '0 16px 40px rgb(15 23 42 / 14%)'),
......
...@@ -86,6 +86,23 @@ ...@@ -86,6 +86,23 @@
--xinyuan-chart-14: #8A6F63; --xinyuan-chart-14: #8A6F63;
--xinyuan-chart-15: #9A8174; --xinyuan-chart-15: #9A8174;
--xinyuan-chart-16: #B5A6A1; --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-grid: #e8e8e8;
--xinyuan-chart-axis: #737373; --xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373; --xinyuan-chart-label: #737373;
...@@ -132,4 +149,21 @@ ...@@ -132,4 +149,21 @@
--surface-tertiary-foreground: oklch(99.11% 0.0000 29.20); --surface-tertiary-foreground: oklch(99.11% 0.0000 29.20);
--warning: oklch(82.03% 0.1388 49.38); --warning: oklch(82.03% 0.1388 49.38);
--warning-foreground: oklch(21.03% 0.0059 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 @@ ...@@ -165,6 +165,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 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-primary: var(--rc-chart-1);
--rc-chart-risk: var(--xinyuan-chart-2); --rc-chart-risk: var(--xinyuan-chart-2);
--rc-chart-warning: var(--rc-chart-6); --rc-chart-warning: var(--rc-chart-6);
......
...@@ -217,7 +217,6 @@ function CategoryDistributionChart({ data, uniqueCategoryCount }) { ...@@ -217,7 +217,6 @@ function CategoryDistributionChart({ data, uniqueCategoryCount }) {
name: item.label, name: item.label,
value: item.count, value: item.count,
})); }));
const colorMap = createResolvedXinyuanChartColorMap(chartData.map((item) => item.name));
if (chartData.length === 0) { if (chartData.length === 0) {
return <ChartEmptyState title="暂无分类数据" />; return <ChartEmptyState title="暂无分类数据" />;
...@@ -232,16 +231,16 @@ function CategoryDistributionChart({ data, uniqueCategoryCount }) { ...@@ -232,16 +231,16 @@ function CategoryDistributionChart({ data, uniqueCategoryCount }) {
data: chartData.map((item) => ({ data: chartData.map((item) => ({
name: item.name, name: item.name,
value: item.value, value: item.value,
itemStyle: { color: colorMap.get(item.name) }, itemStyle: {},
emphasis: { emphasis: {
itemStyle: { color: colorMap.get(item.name), opacity: 1 }, itemStyle: { opacity: 1 },
}, },
blur: { 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 } } }, 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: { tooltip: {
trigger: "item", 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