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 = [`${year} · ${company}`, `状态:${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('
'); }, }, // 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'), ], }; }