feat: 增加不同地区各渠道收入占比图

This commit is contained in:
R524809
2026-09-08 16:53:44 +08:00
parent 44c6437b64
commit bab02e1efd
25 changed files with 888 additions and 29 deletions
+174
View File
@@ -0,0 +1,174 @@
import {
PALETTE, FONT, categoryAxis, titleStyle,
standardLegend, footnote, watermarks, LAYOUT,
} from '../theme.js';
const STATE_LABELS = {
initial: '期初持有',
added: '新建仓',
held: '持续持有',
sold: '清仓',
};
const STATE_COLORS = {
initial: PALETTE.sand,
added: PALETTE.green,
sold: PALETTE.coral,
};
const SHORT_NAMES = {
'美国运通(American Express)': '美国运通',
'可口可乐(Coca-Cola)': '可口可乐',
'华特迪士尼(Walt Disney)': '迪士尼',
'房地美(Freddie Mac)': '房地美',
'吉列(Gillette)': '吉列',
'旅行家集团(Travelers Group)': '旅行家集团',
'华盛顿邮报(Washington Post)': '华盛顿邮报',
'富国银行(Wells Fargo)': '富国银行',
'大都会/ABC(Capital Cities/ABC)': '大都会/ABC',
'盖可保险(GEICO)': '盖可保险',
'甘尼特(Gannett)': '甘尼特',
'PNC 银行(PNC Bank)': 'PNC 银行',
'通用动力(General Dynamics)': '通用动力',
'健力士(Guinness)': '健力士',
"麦当劳(McDonald's)": '麦当劳',
};
function formatShares(value) {
return Number(value).toLocaleString('en-US');
}
function quantityChange(current, previous) {
if (!Number.isFinite(previous)) return '无可比上年数据';
const change = current - previous;
const sign = change > 0 ? '+' : '';
return `${sign}${formatShares(change)} 股`;
}
export function brkHoldingsChart(rows, { width, height }) {
const years = [...new Set(rows.map((row) => row.year))].sort((a, b) => a - b);
const holdingsByYear = new Map(years.map((year) => [year, new Map()]));
const firstYearByCompany = new Map();
const maxSharesByCompany = new Map();
rows.forEach((row) => {
holdingsByYear.get(row.year).set(row.company, row.shares);
firstYearByCompany.set(row.company, Math.min(firstYearByCompany.get(row.company) ?? row.year, row.year));
maxSharesByCompany.set(row.company, Math.max(maxSharesByCompany.get(row.company) ?? 0, row.shares));
});
const companies = [...firstYearByCompany.keys()].sort((a, b) => (
firstYearByCompany.get(a) - firstYearByCompany.get(b) || a.localeCompare(b, 'zh-CN')
));
const dataByState = { initial: [], added: [], held: [], sold: [] };
years.forEach((year, yearIndex) => {
const current = holdingsByYear.get(year);
const previous = yearIndex > 0 ? holdingsByYear.get(years[yearIndex - 1]) : new Map();
companies.forEach((company, companyIndex) => {
const shares = current.get(company);
const priorShares = previous.get(company);
let state;
if (Number.isFinite(shares)) {
state = yearIndex === 0 ? 'initial' : (Number.isFinite(priorShares) ? 'held' : 'added');
} else if (yearIndex > 0 && Number.isFinite(priorShares)) {
state = 'sold';
} else {
return;
}
const relativeSize = Number.isFinite(shares)
? Math.sqrt(shares / maxSharesByCompany.get(company))
: 1;
const color = state === 'held'
? `rgba(59, 110, 165, ${(0.38 + relativeSize * 0.62).toFixed(2)})`
: STATE_COLORS[state];
const labelColor = state === 'initial' || (state === 'held' && relativeSize < 0.55)
? PALETTE.ink
: '#fff';
dataByState[state].push({
value: [yearIndex, companyIndex, shares ?? 0],
shares,
priorShares,
state,
shortName: SHORT_NAMES[company] || company,
itemStyle: { color, borderColor: '#fff', borderWidth: 3 },
label: { color: labelColor },
});
});
});
return {
backgroundColor: '#fff',
title: {
...titleStyle,
text: '伯克希尔主要持仓变动(1988–1997)',
subtext: '每格标注公司简称;持续持有的蓝色越深,表示相对持股数量越大',
},
legend: standardLegend([
STATE_LABELS.initial,
STATE_LABELS.added,
STATE_LABELS.held,
STATE_LABELS.sold,
], { selectedMode: false, itemGap: 20 }),
tooltip: {
trigger: 'item',
textStyle: { fontFamily: FONT },
formatter: (params) => {
const data = params.data;
const company = companies[data.value[1]];
const year = years[data.value[0]];
const lines = [`<b>${year} · ${company}</b>`, `状态:${STATE_LABELS[data.state]}`];
if (Number.isFinite(data.shares)) {
lines.push(`年末持股:${formatShares(data.shares)} 股`);
if (data.state !== 'initial') lines.push(`较上年:${quantityChange(data.shares, data.priorShares)}`);
} else {
lines.push(`上年末持股:${formatShares(data.priorShares)} 股`);
}
return lines.join('<br/>');
},
},
// ECharts 的 heatmap 组件要求声明 visualMap;实际颜色由每个状态单元格的 itemStyle 控制。
visualMap: {
show: false,
min: 0,
max: Math.max(...rows.map((row) => row.shares)),
dimension: 2,
inRange: { color: ['#ffffff'] },
},
grid: { left: 258, right: 48, top: LAYOUT.gridTop, bottom: 72 },
xAxis: {
...categoryAxis,
type: 'category',
data: years.map(String),
splitArea: { show: true, areaStyle: { color: ['#fbfbfa'] } },
},
yAxis: {
...categoryAxis,
type: 'category',
inverse: true,
data: companies,
axisLabel: { ...categoryAxis.axisLabel, fontSize: 12, margin: 14 },
splitArea: { show: true, areaStyle: { color: ['#fbfbfa'] } },
},
series: Object.entries(dataByState).map(([state, data]) => ({
name: STATE_LABELS[state],
type: 'heatmap',
data,
label: {
show: true,
position: 'inside',
fontFamily: FONT,
fontSize: 12,
fontWeight: 600,
formatter: (params) => params.data.shortName,
},
emphasis: { itemStyle: { borderColor: PALETTE.ink, borderWidth: 2 } },
})),
graphic: [
...watermarks(width, height),
footnote('绿色=相对上一年新增;红色=相对上一年清仓;蓝色深浅=相对持股数量 | @思考的Joey'),
],
};
}