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)}%`, ])), }); }