From e5c21c61484d804d66ed6b54776cf0d87847b3e1 Mon Sep 17 00:00:00 2001 From: R524809 Date: Fri, 28 Aug 2026 17:41:48 +0800 Subject: [PATCH] Standardize financial chart colors and improve label placement --- CLAUDE.md | 14 ++++-- docs/方案设计.md | 3 ++ js/charts/ppmt_bridge.js | 10 +--- js/charts/ppmt_core_profit.js | 10 ++-- js/charts/ppmt_liquidity_working_capital.js | 37 ++++++++------ js/charts/ppmt_working_capital_days.js | 54 +++++++++++++++------ js/theme.js | 47 ++++++++++++------ 7 files changed, 114 insertions(+), 61 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 925119e..843be94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ ```js import { - PALETTE, FONT, axisCommon, periodAxis, titleStyle, - lineSeries, barSeries, standardLegend, footnote, watermarks, LAYOUT, + PALETTE, FINANCIAL_COLORS, BRIDGE_COLORS, CHANNEL_COLORS, FONT, axisCommon, periodAxis, titleStyle, + lineSeries, lineDataWithSafeLabels, barSeries, standardLegend, footnote, watermarks, LAYOUT, } from '../theme.js'; ``` @@ -27,13 +27,13 @@ pnpm test # 全量,只在改了 t 全量跑纯属浪费,而且十几张无关图表的输出会盖住本次改动的真实问题。不传参数时才是全量,所以 `pnpm test` 保持向后兼容。 -它会断言 `legend.top`、`grid.top`、折线 `symbolSize`/`borderWidth`,并检查 SVG 字体引号。加新图记得同步往 `regress.mjs` 顶部的 `all` 数组加一行,否则不会被测到。 +它会断言 `legend.top`、`grid.top`、折线 `symbolSize`/`borderWidth`,并检查 SVG 字体引号。回归测试直接遍历 `manifest.json` 的 `chartCatalog`,新增图只要完成 registry 与 manifest 登记就会被测到。 **右侧纵向图例的图会报 `legend.top=middle (期望85)`,这是预期的,不要去"修"。** 那条断言的 85px 基准是给顶部横向图例定的,对 `orient: 'vertical'` + `top: 'middle'` 的图不适用(`ppmt_ip_share`、`ppmt_ip_share_history` 都会报)。 ## 踩过的坑 -**不要用 `...legendStyle`。** 它遗留 `top: 62` 且曾带 `icon: 'roundRect'`——后者会盖掉 ECharts 按系列类型自动生成的图例(折线本该是「短线+空心圆」)。用 `standardLegend()`。 +**优先用 `standardLegend()`,且不要手工设置 `legend.icon`。** ECharts 会按系列类型自动生成图例:柱状图为矩形,折线图为「短线 + 空心圆」。需要特殊位置时可扩展 `legendStyle`,但不得覆盖这一自动类型图标。 **`label.position` 不接受函数。** 写了会被静默忽略、退化成默认位置,实测确认过。要让同一系列内不同点朝向不同,只能逐点覆盖 `data`:`{ value, label: { position: 'top' } }`。 @@ -59,6 +59,12 @@ pnpm test # 全量,只在改了 t ## 配色 +**正式方向:轻快消费。** 柱线组合图的现金流、利润与同比使用 `FINANCIAL_COLORS`,渠道结构使用 `CHANNEL_COLORS`,瀑布图使用 `BRIDGE_COLORS`(蓝色基准、青绿色正向、珊瑚红负向);不要在图表文件里为相同财务/渠道语义重写十六进制颜色。柱状系列负责分类,折线只保留一条高辨识度强调色。 + +**一张图只留一个视觉主角。** 辅助柱使用中等饱和度;避免红、橙、紫等多个强调色同时竞争。分类色要同时拉开色相和明度;折线除颜色外还使用线宽、实虚线、空心圆和标签优先级辅助区分。 + +**柱线组合的标签不能压在柱体端点。** 使用 `lineDataWithSafeLabels(rows, { lineKey, barKeys })`:正值标签在上、负值标签在下,接近柱值时自动增加距离,并配合 ECharts `labelLayout` 兜底;不要按某个年份写特判。 + 先分清两件事,别混用同一个门槛。**选色的功夫要和系列数成正比**——两个系列的图不该选半天。 **① 同一张图内的系列要能互相区分。** 这条永远要满足,是可读性问题: diff --git a/docs/方案设计.md b/docs/方案设计.md index e6f2850..9f92f56 100644 --- a/docs/方案设计.md +++ b/docs/方案设计.md @@ -156,6 +156,9 @@ js/main.js + js/ui.js ← 解析引用、渲染三栏、导出和 新增图表必须复用 `theme.js` 的 `FONT`、`PALETTE`、`LAYOUT`、`periodAxis()`、`standardLegend()`、`lineSeries()`、`barSeries()`、`footnote()` 与 `watermarks()`,而不是在单个图表中重复手写基础样式。 - 财务含义相同的系列应使用固定语义色;IP、区域、品类等多分类数据使用统一分类色板。 +- 柱线组合图采用已确认的“轻快消费”色板:柱状系列用不同色相承载分类,折线只保留一条高辨识度强调色。现金流、利润与同比使用 `FINANCIAL_COLORS`,渠道使用 `CHANNEL_COLORS`;瀑布图使用 `BRIDGE_COLORS`(蓝色基准、青绿色正向、珊瑚红负向)。新图不得在图表文件内散落同等语义的十六进制颜色。 +- 一张图只保留一个视觉主角。辅助柱使用中等饱和度,避免多个红、橙、紫同时争夺注意力;分类图的相邻系列须同时拉开色相与明度。 +- 颜色不是唯一编码:折线还须依靠线宽、实虚线、空心圆点和标签优先级;柱线混合图的折线标签使用 `lineDataWithSafeLabels()` 等通用避让规则,避免文字压在柱端。 - 新图的业务差异由 chart 文件表达;统一视觉由主题文件表达;展示位置与 CSV 由 manifest 表达。 - 新增或调整图表后,必须检查标签、图例、脚注和控制器是否重叠,以及颜色与文字是否有足够对比度。 diff --git a/js/charts/ppmt_bridge.js b/js/charts/ppmt_bridge.js index 7e0be77..5da4713 100644 --- a/js/charts/ppmt_bridge.js +++ b/js/charts/ppmt_bridge.js @@ -1,6 +1,6 @@ import { col } from '../csv.js'; import { - PALETTE, FONT, axisCommon, titleStyle, footnote, watermarks, + PALETTE, BRIDGE_COLORS, FONT, axisCommon, titleStyle, footnote, watermarks, } from '../theme.js'; /** @@ -13,12 +13,6 @@ import { * 税率变化单独成项。恒等式 净利率 = 税前利润率 × (1-税率), * Δ净 = Δ税前×(1-t₁) − 税前₂×Δt,实测闭合误差 0.00pct。 */ -const COLOR = { - total: '#2F5C85', // 首尾锚点 - neg: '#C0392B', // 拖累 - pos: '#1A9E5C', // 利好 -}; - export function ppmtBridgeChart(rows, { width, height }) { const items = col(rows, 'item'); const types = col(rows, 'type'); @@ -92,7 +86,7 @@ export function ppmtBridgeChart(rows, { width, height }) { barWidth: 51, data: values.map((v, i) => ({ value: v, - itemStyle: { color: COLOR[types[i]] }, + itemStyle: { color: BRIDGE_COLORS[types[i]] }, })), label: { show: true, diff --git a/js/charts/ppmt_core_profit.js b/js/charts/ppmt_core_profit.js index 3213b7c..c7b3589 100644 --- a/js/charts/ppmt_core_profit.js +++ b/js/charts/ppmt_core_profit.js @@ -1,6 +1,6 @@ import { col } from '../csv.js'; import { - PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle, + PALETTE, FINANCIAL_COLORS, FONT, axisCommon, periodAxis, titleStyle, legendStyle, footnote, watermarks, LAYOUT, } from '../theme.js'; @@ -90,7 +90,7 @@ export function ppmtCoreProfitChart(rows, { width, height }) { yAxisIndex: 0, data: core, barWidth: 32, - itemStyle: { color: PALETTE.navy }, + itemStyle: { color: FINANCIAL_COLORS.netProfit }, label: { show: true, position: 'top', @@ -107,12 +107,12 @@ export function ppmtCoreProfitChart(rows, { width, height }) { type: 'line', yAxisIndex: 1, data: yoy, - lineStyle: { color: PALETTE.coral, width: 3.08 }, // 与拆解图毛利率线一致 + lineStyle: { color: FINANCIAL_COLORS.cashConversion, width: 3.08 }, // 空心圆点:白心 + 彩色描边。图例会自动继承这个样式, // 渲染成「短线 + 空心圆」 itemStyle: { color: '#fff', - borderColor: PALETTE.coral, + borderColor: FINANCIAL_COLORS.cashConversion, borderWidth: 2.5, // 与 theme.js lineSeries 一致 }, symbol: 'circle', @@ -123,7 +123,7 @@ export function ppmtCoreProfitChart(rows, { width, height }) { position: 'top', fontFamily: FONT, fontSize: 11, - color: PALETTE.coral, + color: FINANCIAL_COLORS.cashConversion, fontWeight: '600', formatter: (p) => { if (p.value === null || p.value === '') return ''; diff --git a/js/charts/ppmt_liquidity_working_capital.js b/js/charts/ppmt_liquidity_working_capital.js index 9f81b4f..abf5516 100644 --- a/js/charts/ppmt_liquidity_working_capital.js +++ b/js/charts/ppmt_liquidity_working_capital.js @@ -1,7 +1,7 @@ import { col } from '../csv.js'; import { - PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle, - lineSeries, footnote, watermarks, LAYOUT, + PALETTE, FINANCIAL_COLORS, FONT, axisCommon, periodAxis, titleStyle, legendStyle, + lineDataWithSafeLabels, lineSeries, barSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; /** 泡泡玛特现金、负债与净营运资本:观察流动性与资金占用。 */ @@ -39,26 +39,35 @@ export function liquidityWorkingCapitalChart(rows, { width, height }) { }, series: [ { - name: '货币资金', - type: 'bar', - data: col(rows, 'cash'), - barWidth: 18, - itemStyle: { color: PALETTE.blue }, + ...barSeries({ + name: '货币资金', + data: col(rows, 'cash'), + color: FINANCIAL_COLORS.investingCashFlow, + barWidth: 18, + formatter: (p) => p.value.toFixed(1), + }), emphasis: { focus: 'series' }, }, { - name: '负债合计', - type: 'bar', - data: col(rows, 'total_liabilities'), - barWidth: 18, - itemStyle: { color: PALETTE.orange }, + ...barSeries({ + name: '负债合计', + data: col(rows, 'total_liabilities'), + color: FINANCIAL_COLORS.financingCashFlow, + barWidth: 18, + formatter: (p) => p.value.toFixed(1), + }), emphasis: { focus: 'series' }, }, { ...lineSeries({ name: '净营运资本', - data: col(rows, 'net_working_capital'), - color: PALETTE.red, + data: lineDataWithSafeLabels(rows, { + lineKey: 'net_working_capital', + barKeys: ['cash', 'total_liabilities'], + // 净营运资本接近货币资金时,标签置于柱体另一侧。 + closePosition: 'bottom', + }), + color: FINANCIAL_COLORS.operatingCashFlow, formatter: (p) => p.value.toFixed(1), width: 3, }), diff --git a/js/charts/ppmt_working_capital_days.js b/js/charts/ppmt_working_capital_days.js index 84d966a..25271bf 100644 --- a/js/charts/ppmt_working_capital_days.js +++ b/js/charts/ppmt_working_capital_days.js @@ -1,18 +1,36 @@ import { col } from '../csv.js'; import { - PALETTE, FONT, axisCommon, periodAxis, titleStyle, legendStyle, + PALETTE, FINANCIAL_COLORS, FONT, axisCommon, periodAxis, titleStyle, legendStyle, lineSeries, footnote, watermarks, LAYOUT, } from '../theme.js'; /** 泡泡玛特营运资本效率:存货、应收、应付与现金转换周期。 */ -export function workingCapitalDaysChart(rows, { width, height }) { - const series = [ - { key: 'inventory_days', name: '存货周转天数', color: PALETTE.orange, width: 2.4 }, - { key: 'receivable_days', name: '应收周转天数', color: PALETTE.blue, width: 2.2 }, - { key: 'payable_days', name: '应付周转天数', color: PALETTE.purple, width: 2.2 }, - { key: 'cash_conversion_cycle', name: '现金转换周期', color: PALETTE.red, width: 3.2 }, - ]; +const SERIES = [ + { key: 'inventory_days', name: '存货周转天数', color: FINANCIAL_COLORS.financingCashFlow, width: 2.4 }, + { key: 'receivable_days', name: '应收周转天数', color: FINANCIAL_COLORS.investingCashFlow, width: 2.2 }, + { key: 'payable_days', name: '应付周转天数', color: FINANCIAL_COLORS.cashConversion, width: 2.2 }, + { key: 'cash_conversion_cycle', name: '现金转换周期', color: FINANCIAL_COLORS.netCashIncrease, width: 3.2 }, +]; +/** + * 同一期间的多条线将标签放到离最近相邻值更远的一侧,确保每个数值都可见。 + */ +function labeledData(rows, key) { + return rows.map((row) => { + const value = row[key]; + const ranked = SERIES.map((series) => row[series.key]).sort((a, b) => b - a); + const rank = ranked.indexOf(value); + const gapUp = rank > 0 ? ranked[rank - 1] - value : Infinity; + const gapDown = rank < ranked.length - 1 ? value - ranked[rank + 1] : Infinity; + + return { + value, + label: { position: gapUp >= gapDown ? 'top' : 'bottom', distance: 9 }, + }; + }); +} + +export function workingCapitalDaysChart(rows, { width, height }) { return { backgroundColor: '#fff', title: { @@ -20,7 +38,7 @@ export function workingCapitalDaysChart(rows, { width, height }) { text: '泡泡玛特营运资本效率', subtext: '现金转换周期 = 存货周转天数 + 应收周转天数 − 应付周转天数', }, - legend: { ...legendStyle, top: LAYOUT.legendTop, data: series.map((item) => item.name) }, + legend: { ...legendStyle, top: LAYOUT.legendTop, data: SERIES.map((item) => item.name) }, grid: { left: 78, right: 42, top: LAYOUT.gridTop, bottom: 90 }, tooltip: { trigger: 'axis', @@ -37,17 +55,25 @@ export function workingCapitalDaysChart(rows, { width, height }) { nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, min: 0, }, - series: series.map((item) => ({ + series: SERIES.map((item) => ({ ...lineSeries({ name: item.name, - data: col(rows, item.key), + data: labeledData(rows, item.key), color: item.color, formatter: (p) => p.value.toFixed(0), width: item.width, }), - label: item.key === 'cash_conversion_cycle' - ? { show: true, color: item.color, fontFamily: FONT, fontSize: 11, formatter: (p) => `${p.value.toFixed(0)}天` } - : { show: false }, + label: { + show: true, + color: item.color, + fontFamily: FONT, + fontSize: 11, + fontWeight: 500, + formatter: (p) => `${p.value.toFixed(0)}天`, + textBorderColor: '#fff', + textBorderWidth: 3, + }, + labelLayout: { hideOverlap: true }, z: item.key === 'cash_conversion_cycle' ? 10 : 1, })), graphic: [ diff --git a/js/theme.js b/js/theme.js index c65011e..e4e4e46 100644 --- a/js/theme.js +++ b/js/theme.js @@ -28,24 +28,34 @@ export const PALETTE = { }; /** - * 试行语义色:柱线组合图中,柱采用低饱和辅助色,折线保留单一强调色。 - * 待页面视觉确认后再固化为新增图表的正式规范。 + * 正式色板:轻快消费。 + * 柱线组合中,柱用不同色相承载分类,折线保留高辨识度的单一强调色。 */ export const FINANCIAL_COLORS = { - operatingCashFlow: '#6E9A83', - investingCashFlow: '#7897B2', - financingCashFlow: '#B18A66', - netCashIncrease: '#A8465C', - netProfit: '#C9885C', - cashConversion: '#6B4A6C', + operatingCashFlow: '#36A28F', + investingCashFlow: '#4D83D1', + financingCashFlow: '#EEA64F', + netCashIncrease: '#D95567', + netProfit: '#F08B5A', + cashConversion: '#8256B2', +}; + +/** + * 瀑布图语义色:基准值、正向贡献、负向贡献。 + * 复用轻快消费财务色板,避免利润率拆解图另起一套颜色。 + */ +export const BRIDGE_COLORS = { + total: FINANCIAL_COLORS.investingCashFlow, + pos: FINANCIAL_COLORS.operatingCashFlow, + neg: FINANCIAL_COLORS.netCashIncrease, }; export const CHANNEL_COLORS = { - retail: '#597A9B', - online: '#C87368', - robotStore: '#98A77A', - wholesale: '#BB9A5C', - revenueGrowth: '#775D8B', + retail: '#3F7FBE', + online: '#E36D63', + robotStore: '#71AD81', + wholesale: '#E4A349', + revenueGrowth: '#8256B2', }; /** 坐标轴通用样式:只留浅色横向网格,去掉轴线的存在感 */ @@ -201,9 +211,12 @@ export function lineSeries({ name, data, color, labelPosition = 'top', formatter /** * 柱线组合图的折线标签:正值放在点上方、负值放在点下方; - * 若与同期间柱值接近,则加大距离,避免文字压在柱体端点附近。 + * 若与同期间柱值接近,则加大距离;也可指定将标签放到柱体另一侧, + * 避免文字压在柱体端点附近。 */ -export function lineDataWithSafeLabels(rows, { lineKey, barKeys, distance = 8, closeDistance = 14 }) { +export function lineDataWithSafeLabels(rows, { + lineKey, barKeys, distance = 8, closeDistance = 14, closePosition, +}) { const values = rows.map((row) => row[lineKey]).filter(Number.isFinite); const range = Math.max(...values) - Math.min(...values) || 1; const threshold = range * 0.06; @@ -217,7 +230,9 @@ export function lineDataWithSafeLabels(rows, { lineKey, barKeys, distance = 8, c return { value, label: { - position: value < 0 ? 'bottom' : 'top', + position: nearestBarGap <= threshold && closePosition + ? closePosition + : (value < 0 ? 'bottom' : 'top'), distance: nearestBarGap <= threshold ? closeDistance : distance, }, };