1041 lines
45 KiB
JavaScript
1041 lines
45 KiB
JavaScript
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)}%`,
|
||
])),
|
||
});
|
||
}
|
||
|