Files
smart-fin-chart/js/charts/brk_holdings.js
T
2026-09-09 15:31:05 +08:00

175 lines
6.0 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 {
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知行记'),
],
};
}