Files
smart-fin-chart/js/charts/catl.js
T
2026-09-21 17:45:21 +08:00

1041 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { col } from '../csv.js';
import {
PALETTE, FONT, axisCommon, categoryAxis, titleStyle, standardLegend,
lineSeries, barSeries, footnote, watermarks, LAYOUT, tooltipOf,
} from '../theme.js';
/**
* 动力电池同业对比图表(宁德时代 / 比亚迪 / LG新能源 / 中创新航 / 国轩高科 / 亿纬锂能)
*
* 三套口径,不要混用:
*
* 【A】规模与产能口径(图 1–3)
* 图 1 / 图 3:SNE Research 全球动力电池装车量与份额(GWh / %)——
* 这是唯一对五家使用同一统计口径的公开数据,因此跨公司可比性最好。
* 注意它统计的是"注册电动车实际装载的电池量",不等于厂商出货量/销量:
* 宁德 2025 年动力电池销量 541GWh,SNE 记 464.7GWh,差额来自未装车库存、
* 非道路与售后等(公司口径销量见 catl_unit_economics.csv)。
* 没有一家公司口径能覆盖这五家(比亚迪并表不披露、LGES 只有收入),
* 所以图 1、图 3 统一叫"装车量"口径,不叫"销量"。
* 数据文件:catl_peer_install*.csv(原 catl_peer_sales*.csv,口径是装车量,已更名)。
* 图 2:各公司自主披露的产能利用率(%)——**这里是三张图里唯一不可比的**。
* 只有宁德(电池系统)、LG新能源(全球生产设施)、中创新航(生产基地)三家披露,
* 且三家的统计范围与分母定义都不同;比亚迪年报只披露车辆产销、国轩高科以
* "产能利用率处于高位"定性带过,后两家的值为券商估算(名称带 *、柱体加斜纹)。
* 因此这张图只能读各自的时间趋势,不能拿 70.5% 与 69.3% 的大小比强弱。
* 数据文件:catl_peer_utilization.csv。
* Top5 名单按 2026H1 SNE 装车量排名:宁德 / 比亚迪 / LG新能源 / 中创新航 / 国轩高科。
*
* 【B】单位经济口径(图 4–11):公司定期报告的收入与毛利率 ÷ 各家销量估算。
*
* 【C】全球前十口径(图 12–14):SNE Research 全球装车量前十榜单(GWh / 份额)。
* 存在的理由:Top5 覆盖率从 2021 年 79.1% 掉到 2026H1 的 72.6%,CR10 同期 90.4%→87.8%,
* 两者缺口(= 第 6~10 名合计份额)从 11.3pp 扩大到 15.2pp —— 份额是从头部流向二线,
* 而不是流向长尾。只看 Top5 会把这 15 个百分点整个切掉,也就看不到当前唯一的增量叙事。
* 样本量口径分工:结构性指标(装车量/份额/增速/集中度)全部用 Top10 ——
* 其中 2026H1 的装车量与同比增速原本是一张独立的 Top5 横截面图,
* 已并入图 13:同一时点、同一批公司拆成两张图只是重复。
* 单位经济学指标(单 Wh 毛利/ASP/利润率)只能用 Top5 以内 ——
* 蜂巢、欣旺达、松下、三星SDI、SK On 都没有可比的"动力电池分部收入 + 动力电池销量"披露。
* 数据文件:global_top10_*.csv / global_region_share.csv。
* 展示顺序由 manifest 决定,图 12–14 位于同业对比 Tab 的第 4–6 位。
* 口径自洽规则:份额合计一律用**已披露份额相加**,不用 GWh ÷ 总量现算 ——
* 官方公布的分区合计(中企 72.4%、日韩 15.4%)都是这么来的,GWh 现算会各自漂 0.1pp。
* "第 6~10 名"另用 前十 − 前五 相减得出,保证三段数字相加自洽(72.6 + 15.2 = 87.8)。
*
* 口径说明:
* - 宁德时代、亿纬锂能:年报分产品披露收入与毛利率,销量可得,单 Wh 值可直接计算
* - 比亚迪:电池业务并入汽车分部、自供走内部转移定价,单 Wh 毛利为"估算 ASP × 估算毛利率"
* - LG新能源:只有全公司营业利润口径(含 ESS 与小电池、含北美 AMPC 补贴),
* 收入含非动力业务,故不出 ASP,单 Wh 值为营业利润口径而非毛利
* - 中创新航:收入按动力 / 储能分列披露,销量为出货估算;**未披露分部毛利率**,
* 表中 16.69%(2025) / 16.71%(2026H1) 为公司整体毛利率(含储能系统),此处作动力代理值
* - 国轩高科:动力收入与分部毛利率均披露,销量为出货估算
* - 宁德时代 2026H1:半年报销量 434GWh,其中动力 325GWh、储能 109GWh
* 名称后缀 * 表示估算,† 表示营业利润口径。
*
* 精度例外:单 Wh 值量级只有 0.03–0.20 元,一位小数无法区分(0.1 与 0.12 都是 0.1),
* 故 ASP / 单 Wh 成本 / 单 Wh 毛利统一保留三位小数,毛利率保留两位。
*/
/** 六家厂商固定配色:跨图一致,不随图表或年份变化 */
const PEER_COLORS = {
catl: PALETTE.orange,
byd: PALETTE.navy,
lges: PALETTE.blue,
calb: PALETTE.green,
guoxuan: PALETTE.purple,
eve: PALETTE.coral,
};
const PEER_SERIES = [
{ key: 'catl', name: '宁德时代', color: PEER_COLORS.catl, main: true },
{ key: 'byd', name: '比亚迪*', color: PEER_COLORS.byd, dashed: true },
{ key: 'lges', name: 'LG新能源†', color: PEER_COLORS.lges, dashed: true },
{ key: 'calb', name: '中创新航*', color: PEER_COLORS.calb, dashed: true },
{ key: 'guoxuan', name: '国轩高科*', color: PEER_COLORS.guoxuan, dashed: true },
{ key: 'eve', name: '亿纬锂能', color: PEER_COLORS.eve },
];
const SOURCE_NOTE = '数据来源:各公司定期报告、SNE Research;* 为估算口径,† 为营业利润口径';
const SOURCE_NOTE_SNE = '数据来源:SNE Research 全球动力电池装车量(2021–2026H1)· 五家同一口径,可直接比较';
/**
* 2026H1 横截面图用:公司名 → 语义色,与折线图保持一致。
* 同时登记带 * / † 后缀的写法,避免两套命名各自维护色值。
*/
const COMPANY_COLORS = {
'宁德时代': PEER_COLORS.catl,
'比亚迪': PEER_COLORS.byd,
'LG新能源': PEER_COLORS.lges,
'中创新航': PEER_COLORS.calb,
'国轩高科': PEER_COLORS.guoxuan,
'亿纬锂能': PEER_COLORS.eve,
'比亚迪*': PEER_COLORS.byd,
'LG新能源†': PEER_COLORS.lges,
'中创新航*': PEER_COLORS.calb,
'国轩高科*': PEER_COLORS.guoxuan,
};
/** 期间轴:2021–2025 为年度、2026H1 为半年,两者混排。
* 不用 theme 的 periodAxis:它会把年度也简写成 21/22,与 26H1 并列时会被误读成半年数据。
* 这里保留四位年份,只把半年简写为 26H1。 */
function catlPeriodAxis(rows) {
return {
...categoryAxis,
data: col(rows, 'period'),
axisLabel: {
...categoryAxis.axisLabel,
formatter: (value) => String(value).replace(/^20(\d{2})H1$/, '$1H1'),
},
};
}
function companyAxis(rows) {
return { ...categoryAxis, data: col(rows, 'company') };
}
const valueAxis = (name, extra = {}) => ({
...axisCommon,
type: 'value',
name,
nameLocation: 'middle',
nameGap: 52,
nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT },
...extra,
});
const percentAxis = (name, extra = {}) => ({
...valueAxis(name),
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
splitLine: { show: false },
...extra,
});
/**
* tooltip 已上移到 theme.js(tooltipOf):它是全站通用的「白心圆点导致默认 marker
* 隐形」修法,不再是本文件私有实现。新增图表直接从 theme.js 引入,不要再抄一份。
*/
const GRID = { left: 78, right: 78, top: LAYOUT.gridTop, bottom: 80 };
function baseOption({
title, subtitle, xAxis, yAxis, series, legend, width, height,
tooltipFormatter, tooltipFormatters, extraNote, sourceNote, grid,
}) {
const notes = [sourceNote ?? SOURCE_NOTE];
if (extraNote) notes.push(extraNote);
return {
backgroundColor: '#fff',
title: { ...titleStyle, text: title, subtext: subtitle },
legend: standardLegend(legend, { itemGap: 20 }),
// 横向条形图需要更宽的左侧公司名留白与右侧数值标签留白,用 grid 覆盖;
// 其余图表沿用 GRID,保证与全站图表的绘图区间距一致。
grid: { ...GRID, ...grid },
tooltip: tooltipOf(series, tooltipFormatter, tooltipFormatters),
xAxis,
yAxis,
series,
graphic: [...watermarks(width, height), footnote(notes.join('\n') + ' | @Joey知行记')],
};
}
/** 只保留 CSV 中真实存在的厂商列,避免缺列画出空线 */
function availableSeries(rows) {
return PEER_SERIES.filter((item) => item.key in (rows[0] || {}));
}
/** 多厂商折线:宁德为主线并标注数值(一图一主角),其余靠图例与 tooltip */
function peerLines(rows) {
return availableSeries(rows).map((item) => {
const width = item.main ? 3.5 : 2.5;
const line = lineSeries({
name: item.name,
data: col(rows, item.key),
color: item.color,
width,
formatter: item.main ? (p) => (Number.isFinite(p.value) ? p.value.toFixed(3) : '') : undefined,
});
return {
...line,
lineStyle: { color: item.color, width, type: item.dashed ? 'dashed' : 'solid' },
label: item.main ? line.label : { show: false },
};
});
}
/**
* 装机量口径的厂商序列(SNE Research)。
* 与 PEER_SERIES 的区别:SNE 对五家使用同一统计口径(注册 EV 的电池装车量),
* 不存在估算或替代口径,因此名称不带 * / † 后缀,折线全部实线。
*/
const SNE_SERIES = [
{ key: 'catl', name: '宁德时代', color: PEER_COLORS.catl, main: true },
{ key: 'byd', name: '比亚迪', color: PEER_COLORS.byd },
{ key: 'lges', name: 'LG新能源', color: PEER_COLORS.lges },
{ key: 'calb', name: '中创新航', color: PEER_COLORS.calb },
{ key: 'guoxuan', name: '国轩高科', color: PEER_COLORS.guoxuan },
];
/**
* 图 2 用:产能利用率序列。
*
* 与 SNE_SERIES 的分工:SNE 是"外部机构对五家同一口径",这里恰恰相反 ——
* 没有统一口径可用,每家是自己定义的分母。因此:
* - 披露产能利用率的三家(宁德 / LG新能源 / 中创新航)保持素名,柱体实心;
* - 不披露的两家取券商估算值,名称加 * 且柱体加白色斜纹。
* 图例里的 * 太容易被忽略,而"披露值"与"估算值"并列在同一张图上是最容易误导的
* 一类图表错误,所以再加一层斜纹做强制视觉区分。
* 颜色仍取自 PEER_COLORS,与装车量 / 份额图保持跨图一致(同一家公司永远同一色)。
*/
const UTIL_SERIES = [
{ key: 'catl', name: '宁德时代', color: PEER_COLORS.catl },
{ key: 'byd', name: '比亚迪*', color: PEER_COLORS.byd, estimated: true },
{ key: 'lges', name: 'LG新能源', color: PEER_COLORS.lges },
{ key: 'calb', name: '中创新航', color: PEER_COLORS.calb },
{ key: 'guoxuan', name: '国轩高科*', color: PEER_COLORS.guoxuan, estimated: true },
];
const SOURCE_NOTE_UTIL =
'数据来源:各公司年报 / 中报、信用评级报告、券商研报(2021–2026H1)· * 为券商估算,非公司披露';
const oneDecimal = (value) => (Number.isFinite(value) ? value.toFixed(1) : '—');
/**
* 图 12 用:集中度边界配色。
*
* CR3 ⊂ CR5 ⊂ CR10 是**嵌套**关系,不是三个并列分类 —— 所以用同一色系的三个明度层级
* 编码,而不是三个不同色相。选琥珀/棕系的原因:宁德=橙、比亚迪=藏青、LG=蓝、
* 中创新航=绿、国轩=紫,公司色谱已占满常用色相,再用绿/蓝会和紧邻的份额图撞语义。
* 同时按线宽分出主次:CR5 加粗,因为"Top5 够不够用"正是这张图要回答的问题。
*/
const CR_SERIES = [
{ key: 'cr3', name: 'CR3(前三家)', color: '#6b4a2b', width: 2.5 },
{ key: 'cr5', name: 'CR5(前五家)', color: '#a8762c', width: 3.5, main: true },
{ key: 'cr10', name: 'CR10(前十家)', color: '#cfae7e', width: 2.5 },
];
/**
* 图 13/图 14 用:国别语义色。
*
* 两张图(横截面排名 + 历史归一化堆叠)共用这一套色,构成"同一编码 + 位置紧邻"的一对;
* 中国沿用站内既有的 #3F7FBE(与泡泡玛特分地区图的"中国"一致),保证跨公司跨页不换色。
* 其余为"其他",是残差项,按规范用低饱和中性色。
*
* 注意:横截面图按国别着色、不按公司着色 —— 全站按公司着色的图(单 Wh 毛利 / ASP / 毛利率)
* 用的是另一套色系,故两张国别图在 manifest 中排在相邻位置、并与公司着色图隔开一张
* (图 12 的琥珀色 CR 系列夹在中间,正好充当这条隔离带)。
*/
const REGION_COLORS = {
中国: '#3F7FBE',
韩国: '#E0A458',
日本: '#C97B84',
};
const REGION_SERIES = [
{ key: 'china', name: '中国', color: REGION_COLORS['中国'], insideText: '#fff' },
{ key: 'korea', name: '韩国', color: REGION_COLORS['韩国'], insideText: PALETTE.ink },
{ key: 'japan', name: '日本', color: REGION_COLORS['日本'], insideText: '#fff' },
{ key: 'others', name: '其他', color: '#C9C2B6', insideText: PALETTE.ink },
];
const SOURCE_NOTE_GLOBAL = '数据来源:SNE Research 全球动力电池装车量(前十榜单,2021–2026H1)';
/** 段高不足约 14px 时放不下 11px 段内标签,需外置 */
const MIN_REGION_INSIDE_PX = 14;
/**
* 图 1 用:装车量分组柱,**逐柱**标注数值(不是只标宁德)。
*
* 5 家 × 6 期 = 30 根柱,一格内并排 5 根、每根约 20px 宽,
* 而 "464.7" 横排约 32px —— 直排必然互相压住,故标签旋转 90°。
* 标签取各自系列色:一图一主角的规矩下,宁德的橙色数值仍是最先被看到的。
*/
function volumeBars(rows) {
return SNE_SERIES
.filter((item) => item.key in (rows[0] || {}))
.map((item) => ({
...barSeries({
name: item.name,
data: col(rows, item.key),
color: item.color,
formatter: (p) => oneDecimal(p.value),
}),
label: {
show: true,
position: 'top',
rotate: 90,
align: 'left',
verticalAlign: 'middle',
distance: 4,
fontFamily: FONT,
fontSize: 11,
color: item.color,
formatter: (p) => oneDecimal(p.value),
overflow: 'none',
},
}));
}
/**
* 图 2 用:产能利用率分组柱,逐柱标注数值。
*
* 与 volumeBars 同构(5 家 × 6 期 = 30 根柱,一格内并排 5 根、每根约 20px),
* 因此沿用 rotate:90 + align:'left' + verticalAlign:'middle' 的竖排标签 ——
* 横排的 "96.9" 约 24px、会比柱还宽而互相压住,竖排只占约 9px。
* 差别只有两处:格式化成百分比;估算序列叠一层白色斜纹(见 UTIL_SERIES 注释)。
* 数值都是三位以内有效数字,"96.9" 竖排高度比装车量图的 "464.7" 更短,余量更宽松。
*/
function utilBars(rows) {
const pct = (p) => (Number.isFinite(p.value) ? p.value.toFixed(1) : '');
return UTIL_SERIES
.filter((item) => item.key in (rows[0] || {}))
.map((item) => ({
...barSeries({
name: item.name,
data: col(rows, item.key),
color: item.color,
formatter: pct,
}),
// 估算序列加斜纹底纹:与"公司披露"并列在同一张图上时必须一眼可分
...(item.estimated
? {
itemStyle: {
color: item.color,
decal: {
symbol: 'rect',
dashArrayX: [1, 0],
dashArrayY: [3, 4],
rotation: -Math.PI / 4,
color: 'rgba(255,255,255,0.85)',
},
},
}
: {}),
label: {
show: true,
position: 'top',
rotate: 90,
align: 'left',
verticalAlign: 'middle',
distance: 4,
fontFamily: FONT,
fontSize: 11,
color: item.color,
formatter: pct,
overflow: 'none',
},
}));
}
/**
* 图 3 用:份额折线的逐点标签。
*
* 朝向按"离最近邻居更远的一侧"定(先例:ppmt_margins.js 的 labeledData,
* 避免奇偶交替让相邻两条线相向靠拢)。
*
* 在其上补一层错位:2022 年比亚迪与 LG新能源同为 13.6%,值并列时上下间隙也相同,
* 只按间隙定向会让两个标签落在同一点完全重合;对并列项逐级加大 distance 拉开。
* label.position 不接受函数,只能逐点覆盖 data。
*/
function shareLabels(rows, keys) {
return rows.map((row) => {
const ordered = keys
.filter((key) => Number.isFinite(row[key]))
.map((key) => ({ key, value: row[key] }))
.sort((a, b) => b.value - a.value);
const byKey = {};
ordered.forEach((item, index) => {
const gapUp = index > 0 ? ordered[index - 1].value - item.value : Infinity;
const gapDown = index < ordered.length - 1 ? item.value - ordered[index + 1].value : Infinity;
const tied = ordered.filter((other, i) => i < index && other.value === item.value).length;
byKey[item.key] = {
value: item.value,
label: {
position: gapUp >= gapDown ? 'top' : 'bottom',
distance: 8 + tied * 16,
},
};
});
return byKey;
});
}
/** 图 3 用:份额多厂商折线,全部系列逐点标注数值,宁德主线加粗 */
function shareLines(rows) {
const keys = SNE_SERIES.filter((item) => item.key in (rows[0] || {})).map((item) => item.key);
const labels = shareLabels(rows, keys);
const pct = (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(1)}%` : '');
return SNE_SERIES
.filter((item) => keys.includes(item.key))
.map((item) => {
const width = item.main ? 3.5 : 2.5;
const line = lineSeries({
name: item.name,
data: rows.map((_, index) => labels[index][item.key] ?? null),
color: item.color,
width,
formatter: pct,
});
return {
...line,
lineStyle: { color: item.color, width, type: 'solid' },
// 5 条线挤在 2–5% 区间,字号降到 12 换取标签之间的净空;白描边已在 labelStyle 里
label: { ...line.label, fontSize: 12 },
};
});
}
const three = (value) => (Number.isFinite(value) ? value.toFixed(3) : '—');
/**
* 图 1:Top5 动力电池装车量走势 —— 行业格局的绝对量基础
*
* 口径:SNE Research 全球动力电池装车量(电池被注册电动车实际装载的量)。
* 它不等于公司年报的出货量/销量:宁德 2025 年动力销量 541GWh,SNE 记 464.7GWh,
* 差额来自未装车库存、非道路与售后等。选它是因为只有它对五家使用同一统计口径。
* 因此标题写"装车量"而不是"销量"。
*/
export function peerInstallChart(rows, { width, height }) {
return baseOption({
title: 'Top5 动力电池装车量走势',
subtitle: '单位:GWh · SNE Research 全球装车量口径(≠ 公司销量)· 2026H1 为半年数据',
// 轴顶取 500(最高柱 464.7 占 93%)而不是再留一档:竖排标签约需 40px,
// 500 的余量刚好够、且不把数值顶进图例区;留到 600 会让上半张图空掉一成四。
yAxis: valueAxis('动力电池装车量(GWh)', { min: 0, max: 500, interval: 100 }),
xAxis: catlPeriodAxis(rows),
series: volumeBars(rows),
legend: SNE_SERIES.map((item) => item.name),
width,
height,
sourceNote: SOURCE_NOTE_SNE,
extraNote: '2021 年宁德时代为 SNE 修订后口径 99.5GWh;全球装车总量 2021–2025 分别为 301.5 / 517.9 / 705.5 / 894.4 / 1187.0GWh,2026H1 为 608.5GWh',
tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(1)} GWh`),
});
}
/**
* 图 2:Top5 动力电池产能利用率 —— "谁在满产、谁在闲置"
*
* 口径见文件头【A】组:这是同业对比 Tab 里唯一**不可横比**的一张。
* 只有宁德(电池系统,含储能)、LG新能源(全球生产设施,按生产金额加权)、
* 中创新航(生产基地)三家披露,三家的分母定义本来就不同;比亚迪年报只披露
* 车辆产销,国轩高科以"产能利用率处于高位"定性带过,两家的值是券商估算。
* 所以这张图的读法定位为"各家自己的时间趋势",不是"谁的利用率更高"。
* 缺披露的年份留空(不是 0)—— 空白本身也是信息:披露质量本身就是竞争力差异。
*
* 轴顶取 110 而不是 100:最高柱 96.9% 的竖排标签还要约 34px,
* 顶到 100 会把标签顶出绘图区(与装车量图取 500 而非 464.7 同一理由)。
* 也没有画 100% 参考线 —— 它正好会穿过宁德的标签,不如脚注一句话清楚。
*/
export function peerUtilizationChart(rows, { width, height }) {
const keys = UTIL_SERIES.filter((item) => item.key in (rows[0] || {}));
return baseOption({
title: 'Top5 动力电池产能利用率',
subtitle: '单位:% · 实心为公司披露(产量÷产能)· 斜纹 * 为券商估算',
xAxis: catlPeriodAxis(rows),
yAxis: percentAxis('产能利用率(%)', { min: 0, max: 110, interval: 10 }),
series: utilBars(rows),
legend: keys.map((item) => item.name),
width,
height,
// 来源 + 两行口径说明 = 三行脚注,默认 bottom:80 只够两行,会把横轴刻度压住
grid: { bottom: 118 },
sourceNote: SOURCE_NOTE_UTIL,
extraNote: '口径:宁德=电池系统(含储能)、LG新能源=全球生产设施、中创新航=生产基地,三家统计范围不同,不可精确横比\n'
+ '比亚迪只披露车辆产销、国轩高科为定性表述,故 * 值取自券商估算;空白 = 公司未披露该期数据',
tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(1)}%`),
});
}
/** 图 3:Top5 全球市占率走势 —— 份额在向头部集中还是被二线稀释 */
export function peerShareChart(rows, { width, height }) {
return baseOption({
title: 'Top5 动力电池全球市占率走势',
subtitle: '单位:% · 按 SNE Research 装车量计算 · 各期数值全标注 · 2026H1 为半年数据',
xAxis: catlPeriodAxis(rows),
yAxis: percentAxis('全球市占率(%)', { min: 0, max: 45, interval: 5 }),
series: shareLines(rows),
legend: SNE_SERIES.map((item) => item.name),
width,
height,
sourceNote: SOURCE_NOTE_SNE,
extraNote: '宁德份额 2021→2026H1 由 33.0% 升至 39.9%;LG新能源由 19.7% 降至 8.6%',
tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(1)}%`),
});
}
/** 图 4:Top6 动力电池单 Wh 毛利走势 —— 护城河是否收敛的直接读数 */
export function peerGpPerWhChart(rows, { width, height }) {
return baseOption({
title: 'Top6 动力电池单 Wh 毛利走势',
subtitle: '单位:元/Wh · 虚线为估算或营业利润口径 · 实线为披露口径',
xAxis: catlPeriodAxis(rows),
yAxis: valueAxis('单 Wh 毛利(元/Wh)', { min: -0.04, max: 0.24, interval: 0.04 }),
series: peerLines(rows),
legend: availableSeries(rows).map((item) => item.name),
width,
height,
tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(3)} 元/Wh`),
});
}
/** 图 5:Top6 动力电池 ASP —— 验证宁德是"高价高利"还是"低价走量" */
export function peerAspChart(rows, { width, height }) {
return baseOption({
title: 'Top6 动力电池 ASP 对比',
subtitle: '单位:元/Wh · 虚线为估算口径 · LG新能源收入含 ESS 与小电池,故不列入',
xAxis: catlPeriodAxis(rows),
yAxis: valueAxis('ASP(元/Wh)', { min: 0.3, max: 1.1, interval: 0.1 }),
series: peerLines(rows),
legend: availableSeries(rows).map((item) => item.name),
width,
height,
tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(3)} 元/Wh`),
});
}
/** 图 6:Top6 毛利率 / 营业利润率走势 */
export function peerMarginChart(rows, { width, height }) {
return baseOption({
title: 'Top6 动力电池毛利率 / 营业利润率',
subtitle: '单位:% · 中创新航为公司整体毛利率(分部未披露)· LG新能源为营业利润率(含北美 AMPC 补贴)',
xAxis: catlPeriodAxis(rows),
yAxis: percentAxis('毛利率 / 营业利润率(%)', { min: -5, max: 30, interval: 5 }),
series: peerLines(rows),
legend: availableSeries(rows).map((item) => item.name),
width,
height,
tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(2)}%`),
});
}
/** 图 7:2026H1 横截面 —— 柱为单 Wh 毛利,折线为毛利率 */
export function peer2026h1Chart(rows, { width, height }) {
const bars = col(rows, 'gp_per_wh').map((value, index) => ({
value,
itemStyle: { color: COMPANY_COLORS[rows[index].company] || PALETTE.grey },
}));
return baseOption({
title: '2026H1 单 Wh 毛利与盈利能力',
subtitle: '柱:元/Wh(左轴)· 折线:毛利率或营业利润率(右轴)',
xAxis: companyAxis(rows),
yAxis: [
valueAxis('单 Wh 毛利(元/Wh)', { min: -0.03, max: 0.15, interval: 0.03 }),
percentAxis('毛利率 / 营业利润率(%)', { min: -5, max: 26, interval: 5 }),
],
series: [
{
...barSeries({
name: '单Wh毛利',
data: bars,
color: PEER_COLORS.catl,
formatter: (p) => (Number.isFinite(p.value) ? p.value.toFixed(3) : '—'),
}),
barWidth: 34,
},
{
...lineSeries({
name: '毛利率/营业利润率',
data: col(rows, 'margin_data'),
color: PALETTE.red,
formatter: (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(1)}%` : ''),
}),
yAxisIndex: 1,
z: 10,
},
],
legend: ['单Wh毛利', '毛利率/营业利润率'],
width,
height,
tooltipFormatter: (value) => (value == null ? '—' : String(value)),
});
}
/** 图 8:宁德 vs 二线均值 —— 单 Wh 毛利差距是否收敛 */
export function peerGpGapChart(rows, { width, height }) {
return baseOption({
title: '宁德时代 vs 二线厂均值:单 Wh 毛利差距',
subtitle: '柱:元/Wh(左轴)· 折线:宁德 ÷ 二线均值倍数(右轴)',
xAxis: catlPeriodAxis(rows),
yAxis: [
valueAxis('单 Wh 毛利(元/Wh)', { min: 0, max: 0.24, interval: 0.04 }),
valueAxis('倍数(×)', { min: 0, max: 3, interval: 0.5, splitLine: { show: false } }),
],
series: [
{ ...barSeries({ name: '宁德时代', data: col(rows, 'catl'), color: PEER_COLORS.catl, formatter: (p) => three(p.value) }), barWidth: 30 },
{ ...barSeries({ name: '二线厂均值', data: col(rows, 'peer_avg'), color: PALETTE.sage, formatter: (p) => three(p.value) }), barWidth: 30 },
{
...lineSeries({
name: '宁德 / 二线均值',
data: col(rows, 'gap_multiple'),
color: PALETTE.red,
formatter: (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(2)}×` : ''),
}),
yAxisIndex: 1,
z: 10,
},
],
legend: ['宁德时代', '二线厂均值', '宁德 / 二线均值'],
width,
height,
extraNote: '二线均值含中创新航、国轩高科,2025 年起含亿纬锂能',
tooltipFormatter: (value) => (value == null ? '—' : String(value)),
});
}
/** 图 9:2026H1 vs 2023 —— 剔除锂价红利后的真实变化 */
export function peerGpChangeChart(rows, { width, height }) {
const changes = rows.map((row) => {
const base = Number(row.gp_2023);
const latest = Number(row.gp_2026h1);
if (!Number.isFinite(base) || !Number.isFinite(latest) || base === 0) return null;
return Number(((latest - base) / Math.abs(base) * 100).toFixed(1));
});
return baseOption({
title: '动力电池单 Wh 毛利:2026H1 vs 2023',
subtitle: '柱:元/Wh(左轴)· 折线:较 2023 年变化幅度(右轴)',
xAxis: companyAxis(rows),
yAxis: [
valueAxis('单 Wh 毛利(元/Wh)', { min: -0.03, max: 0.22, interval: 0.05 }),
percentAxis('较 2023 变化(%)', { min: -120, max: 20, interval: 20 }),
],
series: [
{ ...barSeries({ name: '2023', data: col(rows, 'gp_2023'), color: PALETTE.sand, formatter: (p) => three(p.value) }), barWidth: 24 },
{ ...barSeries({ name: '2026H1', data: col(rows, 'gp_2026h1'), color: PEER_COLORS.catl, formatter: (p) => three(p.value) }), barWidth: 24 },
{
...lineSeries({
name: '较2023变化',
data: changes,
color: PALETTE.red,
formatter: (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(0)}%` : ''),
}),
yAxisIndex: 1,
z: 10,
},
],
legend: ['2023', '2026H1', '较2023变化'],
width,
height,
extraNote: '亿纬锂能 2023 年数据未披露',
tooltipFormatter: (value) => (value == null ? '—' : String(value)),
});
}
/** 图 10:宁德时代自身单位经济 —— ASP、单 Wh 成本与单 Wh 毛利 */
export function catlUnitEconomicsChart(rows, { width, height }) {
return baseOption({
title: '宁德时代动力电池单 Wh 经济性',
subtitle: '单位:元/Wh · ASP(Average Selling Price,不含税平均售价)= 动力电池系统收入 ÷ 动力电池销量',
xAxis: catlPeriodAxis(rows),
yAxis: [
valueAxis('ASP / 单 Wh 成本(元/Wh)', { min: 0, max: 1.1, interval: 0.1 }),
valueAxis('单 Wh 毛利(元/Wh)', { min: 0, max: 0.24, interval: 0.04, splitLine: { show: false } }),
],
series: [
{ ...barSeries({ name: 'ASP', data: col(rows, 'asp'), color: PALETTE.navy, formatter: (p) => three(p.value) }), barWidth: 26 },
{ ...barSeries({ name: '单Wh成本', data: col(rows, 'cost_per_wh'), color: PALETTE.sand, formatter: (p) => three(p.value) }), barWidth: 26 },
{
...lineSeries({
name: '单Wh毛利',
data: col(rows, 'gp_per_wh'),
color: PEER_COLORS.catl,
formatter: (p) => (Number.isFinite(p.value) ? p.value.toFixed(3) : ''),
}),
yAxisIndex: 1,
z: 10,
},
],
legend: ['ASP', '单Wh成本', '单Wh毛利'],
width,
height,
extraNote: 'ASP 与成本均按不含税口径;单 Wh 成本 = ASP − 单 Wh 毛利 · 2026H1 为半年数据',
tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(3)} 元/Wh`),
});
}
/** 图 11:销量与单 Wh 毛利 —— 规模扩张是否以单位盈利为代价 */
export function catlVolumeGpChart(rows, { width, height }) {
return baseOption({
title: '宁德时代动力电池销量与单 Wh 毛利',
subtitle: '柱:销量(GWh,左轴)· 折线:单 Wh 毛利(元/Wh,右轴)· 2026H1 为半年数据',
xAxis: catlPeriodAxis(rows),
yAxis: [
valueAxis('销量(GWh)', { min: 0, max: 600, interval: 100 }),
valueAxis('单 Wh 毛利(元/Wh)', { min: 0, max: 0.24, interval: 0.04, splitLine: { show: false } }),
],
series: [
{ ...barSeries({ name: '动力电池销量', data: col(rows, 'sales_gwh'), color: PALETTE.sage, formatter: (p) => (Number.isFinite(p.value) ? p.value.toFixed(0) : '—') }), barWidth: 40 },
{
...lineSeries({
name: '单Wh毛利',
data: col(rows, 'gp_per_wh'),
color: PEER_COLORS.catl,
formatter: (p) => (Number.isFinite(p.value) ? p.value.toFixed(3) : ''),
}),
yAxisIndex: 1,
z: 10,
},
],
legend: ['动力电池销量', '单Wh毛利'],
width,
height,
tooltipFormatter: (value) => (value == null ? '—' : String(value)),
});
}
/**
* 图 12:全球动力电池装车量集中度 CR3 / CR5 / CR10。
*
* 存在的理由:Top5 覆盖率从 2021 年 79.2% 掉到 2026H1 的 72.7%,CR10 同期 90.4%→87.8%,
* 两者的缺口(= 第 6~10 名合计份额)从 11.2pp 扩大到 15.1pp。也就是说份额是从头部
* 流向二线,而不是流向长尾 —— 只看 Top5 会把这 15 个百分点整个切掉。
* 这条曲线就是"该不该把样本从 Top5 扩到 Top10"的读数依据。
*/
export function globalCrChart(rows, { width, height }) {
return baseOption({
title: '全球动力电池装车量集中度',
subtitle: '单位:% · CRn = 前 n 家厂商合计份额,按 SNE Research 装车量计算 · 2026H1 为半年数据',
xAxis: catlPeriodAxis(rows),
// 下限取 60 而非 0:三条线都在 62–93% 区间,从 0 起画会把全部变化压成一条平线
yAxis: valueAxis('前 n 家合计份额(%)', {
min: 60,
max: 100,
interval: 5,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
}),
series: CR_SERIES.map((item) => lineSeries({
name: item.name,
data: col(rows, item.key),
color: item.color,
width: item.width,
formatter: (p) => (Number.isFinite(p.value) ? `${p.value.toFixed(1)}%` : ''),
})),
legend: CR_SERIES.map((item) => item.name),
width,
height,
sourceNote: SOURCE_NOTE_GLOBAL,
extraNote: 'CR5 与 CR10 缺口(= 第 6~10 名合计)由 2021 年 11.3pp 升至 2026H1 15.2pp,份额由头部流向二线而非长尾',
tooltipFormatter: (value) => (value == null ? '—' : `${Number(value).toFixed(1)}%`),
});
}
/**
* 图 13:2026H1 全球装车量前十排名,带同比增速。
*
* 按国别着色而不是按公司着色:这张图要回答的是"中国 7 席 vs 日韩 3 席",
* 及"Top5 边界切在哪里、切掉的是什么"。公司名已在纵轴上,颜色不必再承担识别功能。
* (因此它与图 14 共用国别色谱,并在 manifest 中与公司着色的图隔开一张。)
*
* 同比增速用**顶部百分比轴的折线**承载(原独立横截面图「2026H1 装车量与同比增速」已并入本图):
* 同一批公司、同一个 2026H1 时点拆成两张图只是重复;合并后一行读完"量、份额、增速"。
*
* 增速数值写进柱标签第二行(红字),而不是标在折线点上 —— 横向排名图里折线会横穿所有柱体,
* 点标签容易与柱标签、Top5 分界文字抢位置;写进柱标签则与编号同处一行,结构性不打架。
* 顶部轴下限取 -10 而非 0:SK On 是前十中唯一的负增长(-6.7%),从 0 起画会被直接截掉,
* 另配一条 0% 虚线参考线,否则读者分不清"点偏左"是低增速还是负增长。
*/
export function globalTop10Chart(rows, { width, height }) {
const grid = { left: 96, right: 170, top: LAYOUT.gridTop, bottom: 118 };
const sum = (list, key) => list.reduce((acc, row) => acc + (Number(row[key]) || 0), 0);
const total = Number(rows[0]?.global_total) || 0;
// 份额用 CSV 里的 share 列(SNE 已披露值)相加,不用 GWh ÷ 总量现算:
// 官方公布的分区合计(中企 72.4%、日韩 15.4%)都是按已披露份额相加得到的,
// GWh 现算会各自多漂 0.1pp,反而对不上官方口径。
// 且"第 6~10 名"用 前十 − 前五 相减得出,保证三段数字自洽(72.6 + 15.2 = 87.8)。
const top5 = rows.slice(0, 5);
const rest = rows.slice(5);
const top5Gwh = sum(top5, 'gwh');
const restGwh = sum(rest, 'gwh');
const top5Share = sum(top5, 'share');
const top10Share = sum(rows, 'share');
const restShare = top10Share - top5Share;
// 分界线落在第 5、6 名之间的类别边界:boundaryGap 下的类别中心为 (i+0.5)×band,
// 故边界 = top + 5×band。用像素算而不是 markLine,避免依赖 ECharts 序数轴插值的实现细节。
const band = (height - grid.top - grid.bottom) / rows.length;
const dividerY = grid.top + 5 * band;
const GROWTH_MIN = -10;
const GROWTH_MAX = 60;
const VOLUME_MAX = 300;
const plotWidth = width - grid.left - grid.right;
const zeroX = grid.left + ((0 - GROWTH_MIN) / (GROWTH_MAX - GROWTH_MIN)) * plotWidth;
// 柱宽按行高取比例,而不是钉一个像素值:改高度时柱宽跟着走,不会突然变得很细或顶满。
const barWidth = Math.round(band * 0.62);
// 增速圆点的像素位置:两个横轴共用同一块画布,圆点按百分比轴换算。
const pxOfGrowth = (value) => grid.left
+ ((Number(value) - GROWTH_MIN) / (GROWTH_MAX - GROWTH_MIN)) * plotWidth;
const DOT_R = 4.5; // theme 里增速圆点 symbolSize 9,半宽即避让半径
const LABEL_GAP = 8; // 标签距柱端的间距(label.distance)
const DOT_CLEAR = 6; // 标签与圆点之间的最小净空
const signedPct = (value) => (Number.isFinite(value)
? `${value > 0 ? '+' : ''}${oneDecimal(value)}%`
: '—');
const bars = rows.map((row) => ({
value: row.gwh,
itemStyle: { color: REGION_COLORS[row.region] || PALETTE.grey },
}));
// 增速圆点的像素位置,交给下面的 labelLayout 做碰撞避让。
const growthDots = rows.map((row) => pxOfGrowth(row.yoy));
// 柱标签两行:第一行量 + 份额,第二行同比增速。
// 第二行用折线同款红色(rich 分段着色),把"这个数字属于那条折线"这层语义带上。
const barLabel = (p) => {
const row = rows[p.dataIndex];
return `{vol|${oneDecimal(row.gwh)} GWh(${oneDecimal(row.share)}%)}`
+ `\n{growth|同比 ${signedPct(row.yoy)}}`;
};
const option = baseOption({
title: '2026H1 全球装车量前十与同比增速',
subtitle: '柱:装车量(GWh,含全球份额)· 圆点:同比增速(%,上轴)',
xAxis: [
{
...axisCommon,
type: 'value',
min: 0,
max: VOLUME_MAX,
interval: 50,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}' },
},
{
...percentAxis('同比增速(%)', {
min: GROWTH_MIN,
max: GROWTH_MAX,
interval: 10,
position: 'top',
// 上轴无轴线,轴名贴到右端;nameGap 要大于轴标签的 margin + 字高(约 21px),
// 否则轴名会压在刻度标签上。右端同时避开了居中的图例。
nameLocation: 'end',
nameGap: 30,
}),
},
],
yAxis: { ...categoryAxis, data: col(rows, 'company'), inverse: true },
series: [
{
...barSeries({
name: '装车量',
data: bars,
color: REGION_COLORS['中国'],
formatter: barLabel,
}),
barWidth,
label: {
show: true,
position: 'right',
distance: LABEL_GAP,
overflow: 'none',
rich: {
vol: { fontFamily: FONT, fontSize: 12, color: PALETTE.ink, lineHeight: 17 },
growth: { fontFamily: FONT, fontSize: 12, color: PALETTE.red, lineHeight: 17 },
},
formatter: barLabel,
},
// 柱端值标签与增速圆点共用同一块画布、却是两套横轴换算,短柱的标签会向右
// 伸进低增速圆点的落点 —— 实测 LG(+8.4%)的圆点压在自己标签的「(8.6%)」上,
// 而圆点是白心填充,等于把那几个字直接挖掉。
// 用 labelLayout 而不是自己算:这里能拿到 ECharts 真实测量出的标签矩形
// (宽 104–122px,按字号估算会差十几个像素,估宽了就会无谓地推、估窄了还是压字)。
labelLayout: (params) => {
const dotX = growthDots[params.dataIndex];
const rect = params.labelRect;
if (!Number.isFinite(dotX) || !rect) return undefined;
// 圆点与标签矩形不相交就不动 —— 否则长柱标签会被无谓地推到圆点右侧
const clear = dotX + DOT_R <= rect.x || dotX - DOT_R >= rect.x + rect.width;
if (clear) return undefined;
return { dx: dotX + DOT_R + DOT_CLEAR - rect.x };
},
},
{
// 只留圆点、不连线。
//
// 横向排名图的纵轴是"名次"而不是时间或连续量,把 10 家依次连起来等于宣称
// 第 1 名的增速会"过渡"到第 2 名 —— 而实际读数是 25.3 → 1.6 → 8.4 → 39.5 …
// 一条横穿全部柱体的锯齿线,既不表意又会盖住柱标签和 Top5 分界文字(实测截图确认)。
// 圆点各自落在自己的行上,位置本身就已经完成了横向比较。
//
// 数值不标在点上:柱标签第二行已经写了(红字),点标签只会与柱标签抢位置。
// 仍用 line 系列而不是 scatter,是为了沿用 theme 的空心圆样式与 tooltip 取色
// (scatter 的 itemStyle.color 是 #fff,tooltip 的色点会变成白色而隐形)。
...lineSeries({
name: '同比增速',
data: col(rows, 'yoy'),
color: PALETTE.red,
width: 2.5,
formatter: (p) => signedPct(p.value),
}),
lineStyle: { color: PALETTE.red, width: 0, opacity: 0 },
xAxisIndex: 1,
z: 10,
label: { show: false },
},
],
legend: ['装车量', '同比增速'],
width,
height,
grid,
sourceNote: SOURCE_NOTE_GLOBAL,
extraNote: `2026H1 全球装车 ${oneDecimal(total)}GWh(+20.0%)· 前十合计占 ${oneDecimal(top10Share)}% · SK On 是前十中唯一负增长(-6.7%)\n中国 7 席(欣旺达重回榜单)· 韩国 2 席 · 日本 1 席;增速前三:亿纬 +51.7%、国轩 +43.3%、蜂巢 +40.9%`,
tooltipFormatters: {
'装车量': (value) => `${Number(value).toFixed(1)} GWh`,
'同比增速': (value) => signedPct(value),
},
});
option.graphic.push(
{
// 0% 参考线。画在柱体之上、增速折线之下(z 5 < 10),
// 否则负增长与"低正增长"在视觉上没有分界。
type: 'line',
silent: true,
z: 5,
shape: { x1: zeroX, y1: grid.top, x2: zeroX, y2: height - grid.bottom },
style: { stroke: PALETTE.axisLine, lineWidth: 1, lineDash: [4, 4] },
},
{
type: 'line',
silent: true,
z: 100,
shape: { x1: grid.left, y1: dividerY, x2: width - grid.right, y2: dividerY },
style: { stroke: PALETTE.red, lineWidth: 1.5, lineDash: [6, 5] },
},
{
type: 'text',
silent: true,
z: 100,
left: 360,
top: dividerY - 26,
style: {
text: `Top5 合计 ${oneDecimal(top5Gwh)} GWh · 占全球 ${oneDecimal(top5Share)}%`,
fill: PALETTE.red,
fontSize: 13,
fontFamily: FONT,
},
},
{
type: 'text',
silent: true,
z: 100,
left: 360,
top: dividerY + 8,
style: {
text: `第 6~10 名合计 ${oneDecimal(restGwh)} GWh · 占 ${oneDecimal(restShare)}%`,
fill: PALETTE.grey,
fontSize: 13,
fontFamily: FONT,
},
},
);
return option;
}
/**
* 图 14:中日韩厂商全球份额结构(归一化堆叠)。
*
* 口径:各期全球装车量前十榜单中,该地区上榜企业的合计份额。中国 5/6/6/6/6/7 家,
* 韩国恒为 3 家(2026H1 起 2 家)、日本恒为松下 1 家。
*
* 为什么仍保留"其他"段:只堆中韩日三段,柱高在 2021 年只有 90.4%,不是真 100%。
* 加上残差段才是完整的归一化占比,也才能看出"其他"(含未进前十厂商)在 2026H1 抬头。
* 但 2026H1 的"其他"里含掉出前十的三星SDI(约 2%),是口径断点,已在脚注标明。
*/
export function globalRegionShareChart(rows, { width, height }) {
const periods = col(rows, 'period');
const totals = rows.map((row) => Number(row.global_total) || 0);
const pxPerPct = (height - GRID.top - GRID.bottom) / 100;
const pctOf = (row, key) => {
const all = REGION_SERIES.reduce((acc, item) => acc + (Number(row[item.key]) || 0), 0);
return all > 0 ? ((Number(row[key]) || 0) / all) * 100 : 0;
};
const segment = (row, item, keyIndex) => {
const pct = pctOf(row, item.key);
if (pct * pxPerPct >= MIN_REGION_INSIDE_PX) {
return { value: pct, label: { position: 'inside', color: item.insideText } };
}
// 矮段:上方还有别的段时外置到柱体右侧,否则标到柱顶上方
const hasAbove = REGION_SERIES.slice(keyIndex + 1).some((r) => (Number(row[r.key]) || 0) > 0);
return {
value: pct,
label: hasAbove
? { position: 'right', color: PALETTE.ink, distance: 4, textBorderColor: '#fff', textBorderWidth: 2 }
: { position: 'top', color: PALETTE.ink, distance: 4 },
};
};
return baseOption({
title: '中日韩厂商全球份额结构',
subtitle: '归一化堆叠,每根柱为当期全球装车量=100% · 按前十榜单内该地区企业合计',
xAxis: catlPeriodAxis(rows),
yAxis: {
...axisCommon,
type: 'value',
min: 0,
max: 100,
interval: 20,
axisLabel: { ...axisCommon.axisLabel, formatter: '{value}%' },
},
series: REGION_SERIES.map((item, keyIndex) => barSeries({
name: item.name,
data: rows.map((row) => segment(row, item, keyIndex)),
color: item.color,
barWidth: 46,
stack: 'region-share',
label: {
show: true,
position: 'inside',
fontSize: 12,
fontFamily: FONT,
fontWeight: 500,
formatter: (p) => `${p.value.toFixed(1)}%`,
},
emphasis: { focus: 'series' },
})),
legend: REGION_SERIES.map((item) => item.name),
width,
height,
// 脚注三行(来源 + 两行口径说明)共约 54px,默认 bottom:80 会被横轴标签压住
grid: { bottom: 118 },
sourceNote: SOURCE_NOTE_GLOBAL,
extraNote: '中国 48.2%→72.4%,韩国 30.2%→11.7%,日本 12.0%→3.7%\n2026H1 三星SDI 掉出前十、份额计入「其他」,故「其他」段抬升含口径断点',
tooltipFormatters: Object.fromEntries(REGION_SERIES.map((item) => [
item.name,
(value) => `${Number(value).toFixed(1)}%`,
])),
});
}