feat: 增加不同地区各渠道收入占比图
This commit is contained in:
@@ -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'),
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user