import { PALETTE, FONT, axisCommon, categoryAxis, titleStyle, lineSeries, footnote, watermarks, } from '../theme.js'; /** 圆角卡片:ECharts 的 graphic.group + rect + 多段文字 */ function infoCard({ left, top, width, title, lines, fill, stroke, titleColor }) { const children = [ { type: 'rect', shape: { x: 0, y: 0, width, height: 108, r: 8 }, style: { fill, stroke, lineWidth: 1 }, }, { type: 'text', style: { x: 20, y: 20, text: title, fill: titleColor, fontSize: 15, fontWeight: 'bold', fontFamily: FONT, }, }, ]; lines.forEach((line, i) => { children.push({ type: 'text', style: { x: 20, y: 52 + i * 22, text: line, fill: '#555', fontSize: 12.5, fontFamily: FONT, }, }); }); return { type: 'group', left, top, silent: true, children }; } /** * 图 5:人民币兑美元两种口径对比 * 只有两个时间点,重点在文字说明和端点标注,不在曲线本身 */ export function fxChart(rows, { width, height }) { const stages = rows.map((r) => r.stage); const mid = rows.map((r) => r.mid_price); const spot = rows.map((r) => r.onshore_spot); // label.position 不接受函数(写函数会被静默忽略,退化成默认位置), // 想让首尾点位置不同只能逐点覆盖:起点标在上方,末点标在右侧 const endpointLabels = (values) => values.map((v, i) => ({ value: v, label: { position: i === values.length - 1 ? 'right' : 'top' }, })); // 偏离点数 = (中间价 - 即期)× 10000 const gapStart = Math.round((mid[0] - spot[0]) * 10000); const gapEnd = Math.round((mid[1] - spot[1]) * 10000); const midGain = Math.round((mid[0] - mid[1]) * 10000); const spotGain = Math.round((spot[0] - spot[1]) * 10000); const midPct = (((mid[0] - mid[1]) / mid[0]) * 100).toFixed(1); const spotPct = (((spot[0] - spot[1]) / spot[0]) * 100).toFixed(1); const cardWidth = 340; const cardTop = 132; return { backgroundColor: '#fff', title: { ...titleStyle, text: '人民币兑美元的两种「升值」口径:中间价 vs 在岸即期', subtext: '口径区别在「谁定的价」:中间价是央行每日公布的锚,即期是市场在锚附近交易出来的成交价', top: 20, }, grid: { left: 96, right: 110, top: 340, bottom: 88 }, tooltip: { trigger: 'axis', textStyle: { fontFamily: FONT } }, xAxis: { ...categoryAxis, boundaryGap: ['12%', '12%'], data: stages, }, yAxis: { ...axisCommon, type: 'value', name: 'USDCNY(数值越小=人民币越强)', nameLocation: 'middle', nameGap: 62, nameTextStyle: { color: PALETTE.grey, fontSize: 13, fontFamily: FONT }, min: 6.75, max: 7.05, interval: 0.1, axisLabel: { ...axisCommon.axisLabel, formatter: (v) => v.toFixed(2), }, }, series: [ { ...lineSeries({ name: '中间价', data: mid, color: '#3b6ea5', formatter: (p) => p.value.toFixed(4), }), data: endpointLabels(mid), symbolSize: 11, itemStyle: { color: '#3b6ea5' }, label: { show: true, fontSize: 15, fontWeight: 'bold', color: '#3b6ea5', fontFamily: FONT, distance: 10, formatter: (p) => p.value.toFixed(4), }, }, { ...lineSeries({ name: '在岸即期', data: spot, color: PALETTE.coral, formatter: (p) => p.value.toFixed(4), }), data: endpointLabels(spot), symbolSize: 11, itemStyle: { color: PALETTE.coral }, label: { show: true, fontSize: 15, fontWeight: 'bold', color: PALETTE.coral, fontFamily: FONT, distance: 10, formatter: (p) => p.value.toFixed(4), }, }, ], graphic: [ ...watermarks(width, height), infoCard({ left: 88, top: cardTop, width: cardWidth, title: '中间价(央行公布的「锚」)', lines: [ '每交易日 9:15 由央行公布:做市商报价', '加权+逆周期因子,是当日波动中心与政策信号', ], fill: '#eef4fa', stroke: '#c7dcf0', titleColor: '#2f5f8f', }), infoCard({ left: 88 + cardWidth + 24, top: cardTop, width: cardWidth, title: '在岸即期(市场成交价)', lines: [ '银行间外汇市场实时成交形成,16:30 收盘价', '最常被引用;每日只能在中间价 ±2% 区间内波动', ], fill: '#fdf1ed', stroke: '#f5d3c8', titleColor: '#c05a45', }), // 图例改成两行文字说明,信息量比色块图例大 { type: 'text', left: 108, top: 272, silent: true, style: { text: `● 中间价:${mid[0]} → ${mid[1]},累计升值 ${midGain}点(+${midPct}%)`, fill: '#3b6ea5', fontSize: 14, fontFamily: FONT, }, }, { type: 'text', left: 108, top: 296, silent: true, style: { text: `● 在岸即期:${spot[0]} → ${spot[1]},累计升值 ${spotGain}点(+${spotPct}%)`, fill: PALETTE.coral, fontSize: 14, fontFamily: FONT, }, }, footnote( `注:涨幅差${midGain - spotGain}点=期初偏离${gapStart}−期末偏离${gapEnd}(即期偏强收窄)|` + '汇兑重估用市场汇率而非中间价|数据:新华财经|Joey 谨制' ), ], }; }