charts.js 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. import { palette as C, SCORE_BANDS } from './data'
  2. const tipOpt = {
  3. backgroundColor: 'rgba(12,43,94,.94)',
  4. borderWidth: 0,
  5. textStyle: { color: '#fff', fontSize: 12 },
  6. padding: [8, 12],
  7. extraCssText: 'box-shadow:0 6px 18px rgba(12,43,94,.3);border-radius:6px;',
  8. }
  9. function axisStyle(opts = {}) {
  10. return {
  11. axisLine: { lineStyle: { color: '#E4E9F2' } },
  12. axisTick: { show: false },
  13. axisLabel: { color: C.mut, fontSize: 11 },
  14. splitLine: { lineStyle: { color: '#F2F5FA', type: 'dashed' } },
  15. ...opts,
  16. }
  17. }
  18. // ===== 综合评分仪表盘 =====
  19. export function scoreGaugeOption(current, previous) {
  20. const segColors = [
  21. [SCORE_BANDS[0].hi / 10, SCORE_BANDS[0].axisColor],
  22. [SCORE_BANDS[1].hi / 10, SCORE_BANDS[1].axisColor],
  23. [SCORE_BANDS[2].hi / 10, SCORE_BANDS[2].axisColor],
  24. [SCORE_BANDS[3].hi / 10, SCORE_BANDS[3].axisColor],
  25. [1, SCORE_BANDS[4].axisColor],
  26. ]
  27. return {
  28. tooltip: {
  29. ...tipOpt,
  30. formatter: (p) => `${p.name}:${p.value.toFixed(2)} 分`,
  31. },
  32. series: [
  33. {
  34. type: 'gauge',
  35. startAngle: 215,
  36. endAngle: -35,
  37. min: 0,
  38. max: 10,
  39. radius: '88%',
  40. center: ['50%', '62%'],
  41. progress: { show: false },
  42. axisLine: {
  43. lineStyle: {
  44. width: 24,
  45. color: segColors,
  46. shadowColor: 'rgba(0,0,0,0.05)',
  47. shadowBlur: 6,
  48. },
  49. },
  50. pointer: {
  51. length: '64%',
  52. width: 5,
  53. itemStyle: { color: C.ink },
  54. },
  55. anchor: {
  56. show: true,
  57. showAbove: true,
  58. size: 14,
  59. itemStyle: {
  60. borderColor: C.ink,
  61. borderWidth: 2,
  62. color: '#fff',
  63. },
  64. },
  65. axisTick: {
  66. distance: -24,
  67. length: 6,
  68. lineStyle: { color: '#fff', width: 1 },
  69. },
  70. splitLine: {
  71. distance: -28,
  72. length: 12,
  73. lineStyle: { color: '#fff', width: 2 },
  74. },
  75. axisLabel: {
  76. distance: -36,
  77. color: C.mut,
  78. fontSize: 10,
  79. formatter: (v) => v.toFixed(0),
  80. },
  81. title: { show: false },
  82. detail: {
  83. valueAnimation: true,
  84. formatter: (v) => `{val|${v.toFixed(2)}}{sub|分}`,
  85. rich: {
  86. val: { fontSize: 38, fontWeight: 700, color: C.ink, fontFamily: 'DIN, sans-serif' },
  87. sub: { fontSize: 14, color: C.mut, padding: [0, 0, 0, 4] },
  88. },
  89. offsetCenter: [0, '72%'],
  90. },
  91. data: [{ value: current, name: '综合评分' }],
  92. },
  93. ],
  94. }
  95. }
  96. // ===== 一级类别综合得分(柱状对比 本期 vs 上期) =====
  97. export function categoryBarOption(currList, prevList) {
  98. const cats = currList.map((c) => c.name)
  99. return {
  100. tooltip: {
  101. ...tipOpt,
  102. trigger: 'axis',
  103. axisPointer: { type: 'shadow' },
  104. },
  105. legend: {
  106. data: ['本期', '上期'],
  107. top: 4,
  108. textStyle: { color: C.mut, fontSize: 11 },
  109. itemWidth: 12,
  110. },
  111. grid: { top: 30, left: 8, right: 24, bottom: 28, containLabel: true },
  112. xAxis: {
  113. type: 'category',
  114. data: cats,
  115. ...axisStyle({ axisLabel: { color: C.mut, fontSize: 11 } }),
  116. },
  117. yAxis: {
  118. type: 'value',
  119. min: 0,
  120. max: 10,
  121. interval: 2,
  122. ...axisStyle(),
  123. },
  124. series: [
  125. {
  126. name: '本期',
  127. type: 'bar',
  128. barWidth: 22,
  129. data: currList.map((c) => c.score.toFixed(2)),
  130. itemStyle: { color: C.brand, borderRadius: [3, 3, 0, 0] },
  131. label: { show: true, position: 'top', color: C.ink, fontSize: 11, fontWeight: 600 },
  132. },
  133. {
  134. name: '上期',
  135. type: 'bar',
  136. barWidth: 22,
  137. data: prevList.map((c) => c.score.toFixed(2)),
  138. itemStyle: { color: C.faint, borderRadius: [3, 3, 0, 0], opacity: 0.6 },
  139. label: { show: true, position: 'top', color: C.mut, fontSize: 11 },
  140. },
  141. ],
  142. }
  143. }
  144. // ===== 分项评分雷达图 =====
  145. export function factorRadarOption(factors) {
  146. return {
  147. tooltip: {
  148. ...tipOpt,
  149. formatter: (p) => `${p.name}:${p.value.toFixed(2)} 分`,
  150. },
  151. legend: {
  152. data: ['本期', '上期'],
  153. top: 4,
  154. textStyle: { color: C.mut, fontSize: 11 },
  155. itemWidth: 12,
  156. },
  157. radar: {
  158. indicator: factors.map((f) => ({ name: f.name, max: 10 })),
  159. center: ['50%', '56%'],
  160. radius: '68%',
  161. splitNumber: 5,
  162. axisName: { color: C.mut, fontSize: 11 },
  163. splitLine: { lineStyle: { color: '#E4E9F2' } },
  164. splitArea: {
  165. areaStyle: {
  166. color: ['#FAFBFE', '#fff'],
  167. },
  168. },
  169. axisLine: { lineStyle: { color: '#E4E9F2' } },
  170. },
  171. series: [
  172. {
  173. type: 'radar',
  174. symbolSize: 4,
  175. emphasis: { areaStyle: { opacity: 0.4 } },
  176. data: [
  177. {
  178. name: '本期',
  179. value: factors.map((f) => f.current),
  180. lineStyle: { color: C.brand, width: 2 },
  181. itemStyle: { color: C.brand },
  182. areaStyle: { color: 'rgba(21,82,208,0.18)' },
  183. },
  184. {
  185. name: '上期',
  186. value: factors.map((f) => f.previous),
  187. lineStyle: { color: C.faint, width: 1.5, type: 'dashed' },
  188. itemStyle: { color: C.faint },
  189. areaStyle: { color: 'rgba(152,165,189,0.18)' },
  190. },
  191. ],
  192. },
  193. ],
  194. }
  195. }
  196. // ===== 类别贡献度(横向条形图,按权重×得分贡献) =====
  197. export function categoryContribOption(categoryWeighted) {
  198. const cats = categoryWeighted.map((c) => c.name)
  199. return {
  200. tooltip: {
  201. ...tipOpt,
  202. trigger: 'axis',
  203. axisPointer: { type: 'shadow' },
  204. formatter: (p) => {
  205. const i = p[0].dataIndex
  206. const c = categoryWeighted[i]
  207. return `${c.name}<br/>权重 ${c.weight.toFixed(2)}% · 加权贡献 ${(c.score * c.weight / 100).toFixed(3)}`
  208. },
  209. },
  210. grid: { top: 16, left: 8, right: 24, bottom: 28, containLabel: true },
  211. xAxis: {
  212. type: 'value',
  213. min: 0,
  214. max: 10,
  215. ...axisStyle(),
  216. },
  217. yAxis: {
  218. type: 'category',
  219. data: cats,
  220. ...axisStyle({ axisLabel: { color: C.ink, fontSize: 11.5 } }),
  221. },
  222. series: [
  223. {
  224. type: 'bar',
  225. barWidth: 18,
  226. data: categoryWeighted.map((c) => c.score.toFixed(2)),
  227. itemStyle: {
  228. color: (params) => categoryWeighted[params.dataIndex].color,
  229. borderRadius: [0, 4, 4, 0],
  230. },
  231. label: { show: true, position: 'right', color: C.ink, fontSize: 11, fontWeight: 600 },
  232. markLine: {
  233. symbol: ['none', 'none'],
  234. data: [
  235. { xAxis: 4, lineStyle: { color: C.mut, type: 'dashed', width: 1 }, label: { show: true, position: 'end', formatter: '中性下沿 4', color: C.mut, fontSize: 10 } },
  236. { xAxis: 6, lineStyle: { color: C.mut, type: 'dashed', width: 1 }, label: { show: true, position: 'end', formatter: '中性上沿 6', color: C.mut, fontSize: 10 } },
  237. ],
  238. },
  239. },
  240. ],
  241. }
  242. }
  243. // ===== 4.2.2 新增板块 =====
  244. // ===== 双系列 营收/利润同比 =====
  245. export function revenueProfitLineOption(series) {
  246. const dates = series.map((s) => s[0])
  247. const rev = series.map((s) => s[1])
  248. const pro = series.map((s) => s[2])
  249. return {
  250. tooltip: {
  251. ...tipOpt,
  252. trigger: 'axis',
  253. axisPointer: { type: 'line' },
  254. valueFormatter: (v) => `${v.toFixed(2)}%`,
  255. },
  256. legend: {
  257. data: [
  258. { name: '营收同比', itemStyle: { color: C.brand } },
  259. { name: '利润同比', itemStyle: { color: C.up } },
  260. ],
  261. top: 4,
  262. textStyle: { color: C.mut, fontSize: 11 },
  263. itemWidth: 14,
  264. },
  265. grid: { top: 36, left: 50, right: 24, bottom: 32, containLabel: true },
  266. xAxis: { type: 'category', data: dates, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10.5 } }) },
  267. yAxis: { type: 'value', ...axisStyle({ axisLabel: { color: C.mut, fontSize: 11, formatter: '{value}%' } }) },
  268. series: [
  269. { name: '营收同比', type: 'line', data: rev, symbol: 'none', smooth: true, lineStyle: { color: C.brand, width: 2.2 } },
  270. { name: '利润同比', type: 'line', data: pro, symbol: 'none', smooth: true, lineStyle: { color: C.up, width: 2.2 } },
  271. ],
  272. }
  273. }
  274. // ===== PE / PB 估值带 =====
  275. function priceBandOption(series, opts, closeSeries) {
  276. const dates = series.map((s) => s[0])
  277. const mid = series.map((s) => s[1])
  278. const up = series.map((s) => s[2])
  279. const dn = series.map((s) => s[3])
  280. const cur = series.map((s) => s[4])
  281. const hasClose = Array.isArray(closeSeries) && closeSeries.length > 0
  282. const closeMap = hasClose ? new Map(closeSeries.map(([d, v]) => [d, v])) : null
  283. const closeAligned = hasClose ? dates.map((d) => (closeMap.has(d) ? closeMap.get(d) : null)) : []
  284. const lastClose = hasClose ? closeSeries[closeSeries.length - 1][1] : null
  285. const lastCloseDate = hasClose ? closeSeries[closeSeries.length - 1][0] : null
  286. const legendData = [
  287. { name: '当前', itemStyle: { color: opts.curColor } },
  288. { name: '上轨', itemStyle: { color: C.faint } },
  289. { name: '中枢', itemStyle: { color: C.mut } },
  290. { name: '下轨', itemStyle: { color: C.faint } },
  291. ]
  292. if (hasClose) legendData.push({ name: '指数走势', itemStyle: { color: C.warn } })
  293. return {
  294. tooltip: {
  295. ...tipOpt,
  296. trigger: 'axis',
  297. axisPointer: { type: 'line' },
  298. valueFormatter: (v) => v.toFixed(opts.toFixed || 2),
  299. },
  300. legend: {
  301. data: legendData,
  302. top: 4,
  303. textStyle: { color: C.mut, fontSize: 11 },
  304. itemWidth: 14,
  305. },
  306. grid: { top: 36, left: 50, right: hasClose ? 60 : 24, bottom: 32, containLabel: true },
  307. xAxis: { type: 'category', data: dates, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10.5 } }) },
  308. yAxis: hasClose
  309. ? [
  310. { type: 'value', name: opts.toFixed === 3 ? 'PB' : 'PE', nameTextStyle: { color: opts.curColor, fontSize: 10.5, padding: [0, 0, 0, -28] }, position: 'left', ...axisStyle() },
  311. { type: 'value', name: '指数走势', nameTextStyle: { color: C.warn, fontSize: 10.5, padding: [0, -28, 0, 0] }, position: 'right', ...axisStyle({ splitLine: { show: false } }) },
  312. ]
  313. : { type: 'value', ...axisStyle() },
  314. series: [
  315. { name: '下轨', type: 'line', data: dn, symbol: 'none', smooth: true, lineStyle: { color: C.faint, type: 'dashed', width: 1 }, z: 1 },
  316. { name: '上轨', type: 'line', data: up, symbol: 'none', smooth: true, lineStyle: { color: C.faint, type: 'dashed', width: 1 }, z: 1 },
  317. {
  318. name: '中枢', type: 'line', data: mid, symbol: 'none', smooth: true, lineStyle: { color: C.mut, width: 1.2 }, z: 2,
  319. areaStyle: {
  320. color: {
  321. type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
  322. colorStops: [
  323. { offset: 0, color: 'rgba(152,165,189,0.10)' },
  324. { offset: 1, color: 'rgba(152,165,189,0.02)' },
  325. ],
  326. },
  327. },
  328. },
  329. {
  330. name: '当前', type: 'line', data: cur, symbol: 'none', smooth: true, lineStyle: { color: opts.curColor, width: 2.4 }, z: 3,
  331. markPoint: {
  332. symbol: 'circle', symbolSize: 8,
  333. itemStyle: { color: opts.curColor, borderColor: '#fff', borderWidth: 2 },
  334. label: { formatter: (p) => p.value.toFixed(opts.toFixed || 2), color: opts.curColor, fontSize: 10.5, position: 'top', fontWeight: 600 },
  335. data: [{ name: '当前', value: cur[cur.length - 1], xAxis: dates[dates.length - 1], yAxis: cur[cur.length - 1] }],
  336. },
  337. },
  338. ...(hasClose ? [{
  339. name: '指数走势',
  340. type: 'line',
  341. yAxisIndex: 1,
  342. data: closeAligned,
  343. symbol: 'none',
  344. smooth: true,
  345. lineStyle: { color: C.warn, width: 1.6 },
  346. z: 4,
  347. markPoint: {
  348. symbol: 'circle', symbolSize: 7,
  349. itemStyle: { color: C.warn, borderColor: '#fff', borderWidth: 2 },
  350. label: { formatter: (p) => p.value.toFixed(0), color: C.warn, fontSize: 10, position: 'bottom', fontWeight: 600 },
  351. data: [{ name: '当前指数走势', value: lastClose, xAxis: lastCloseDate, yAxis: lastClose }],
  352. },
  353. }] : []),
  354. ],
  355. }
  356. }
  357. export function peBandOption(series, closeSeries) { return priceBandOption(series, { curColor: C.brand, toFixed: 2 }, closeSeries) }
  358. export function pbBandOption(series, closeSeries) { return priceBandOption(series, { curColor: C.brandD, toFixed: 3 }, closeSeries) }
  359. // ===== 股息率 =====
  360. export function dividendLineOption(series, closeSeries) {
  361. const dates = series.map((s) => s[0])
  362. const values = series.map((s) => s[1])
  363. const hasClose = Array.isArray(closeSeries) && closeSeries.length > 0
  364. const closeMap = hasClose ? new Map(closeSeries.map(([d, v]) => [d, v])) : null
  365. const closeAligned = hasClose ? dates.map((d) => (closeMap.has(d) ? closeMap.get(d) : null)) : []
  366. const lastClose = hasClose ? closeSeries[closeSeries.length - 1][1] : null
  367. const lastCloseDate = hasClose ? closeSeries[closeSeries.length - 1][0] : null
  368. const legendData = [{ name: '股息率', itemStyle: { color: C.warn } }]
  369. if (hasClose) legendData.push({ name: '指数走势', itemStyle: { color: C.brand } })
  370. return {
  371. tooltip: {
  372. ...tipOpt,
  373. trigger: 'axis',
  374. axisPointer: { type: 'line' },
  375. valueFormatter: (v) => `${v.toFixed(2)}%`,
  376. },
  377. legend: {
  378. data: legendData,
  379. top: 4,
  380. textStyle: { color: C.mut, fontSize: 11 },
  381. itemWidth: 14,
  382. },
  383. grid: { top: 36, left: 50, right: hasClose ? 60 : 24, bottom: 32, containLabel: true },
  384. xAxis: { type: 'category', data: dates, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10.5 } }) },
  385. yAxis: hasClose
  386. ? [
  387. { type: 'value', name: '股息率', nameTextStyle: { color: C.warn, fontSize: 10.5, padding: [0, 0, 0, -28] }, position: 'left', ...axisStyle({ axisLabel: { color: C.mut, fontSize: 11, formatter: '{value}%' } }) },
  388. { type: 'value', name: '指数走势', nameTextStyle: { color: C.brand, fontSize: 10.5, padding: [0, -28, 0, 0] }, position: 'right', ...axisStyle({ splitLine: { show: false } }) },
  389. ]
  390. : { type: 'value', ...axisStyle({ axisLabel: { color: C.mut, fontSize: 11, formatter: '{value}%' } }) },
  391. series: [
  392. {
  393. name: '股息率',
  394. type: 'line',
  395. data: values,
  396. symbol: 'none',
  397. smooth: true,
  398. lineStyle: { color: C.warn, width: 2.4 },
  399. areaStyle: {
  400. color: {
  401. type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
  402. colorStops: [
  403. { offset: 0, color: 'rgba(232,145,45,0.28)' },
  404. { offset: 1, color: 'rgba(232,145,45,0.02)' },
  405. ],
  406. },
  407. },
  408. markPoint: {
  409. symbol: 'circle', symbolSize: 8,
  410. itemStyle: { color: C.warn, borderColor: '#fff', borderWidth: 2 },
  411. label: { formatter: (p) => `${p.value.toFixed(2)}%`, color: C.warn, fontSize: 10.5, position: 'top', fontWeight: 600 },
  412. data: [{ name: '当前', value: values[values.length - 1], xAxis: dates[dates.length - 1], yAxis: values[values.length - 1] }],
  413. },
  414. },
  415. ...(hasClose ? [{
  416. name: '指数走势',
  417. type: 'line',
  418. yAxisIndex: 1,
  419. data: closeAligned,
  420. symbol: 'none',
  421. smooth: true,
  422. lineStyle: { color: C.brand, width: 1.6 },
  423. markPoint: {
  424. symbol: 'circle', symbolSize: 7,
  425. itemStyle: { color: C.brand, borderColor: '#fff', borderWidth: 2 },
  426. label: { formatter: (p) => p.value.toFixed(0), color: C.brand, fontSize: 10, position: 'bottom', fontWeight: 600 },
  427. data: [{ name: '当前指数走势', value: lastClose, xAxis: lastCloseDate, yAxis: lastClose }],
  428. },
  429. }] : []),
  430. ],
  431. }
  432. }
  433. // ===== ERP =====
  434. export function erpLineOption(series, closeSeries) {
  435. const dates = series.map((s) => s[0])
  436. const values = series.map((s) => s[1])
  437. const hasClose = Array.isArray(closeSeries) && closeSeries.length > 0
  438. const closeMap = hasClose ? new Map(closeSeries.map(([d, v]) => [d, v])) : null
  439. const closeAligned = hasClose ? dates.map((d) => (closeMap.has(d) ? closeMap.get(d) : null)) : []
  440. const lastClose = hasClose ? closeSeries[closeSeries.length - 1][1] : null
  441. const lastCloseDate = hasClose ? closeSeries[closeSeries.length - 1][0] : null
  442. const legendData = [{ name: 'ERP', itemStyle: { color: C.brandD } }]
  443. if (hasClose) legendData.push({ name: '指数走势', itemStyle: { color: C.warn } })
  444. return {
  445. tooltip: {
  446. ...tipOpt,
  447. trigger: 'axis',
  448. axisPointer: { type: 'line' },
  449. valueFormatter: (v) => `${v.toFixed(2)}%`,
  450. },
  451. legend: {
  452. data: legendData,
  453. top: 4,
  454. textStyle: { color: C.mut, fontSize: 11 },
  455. itemWidth: 14,
  456. },
  457. grid: { top: 36, left: 50, right: hasClose ? 60 : 24, bottom: 32, containLabel: true },
  458. xAxis: { type: 'category', data: dates, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10.5 } }) },
  459. yAxis: hasClose
  460. ? [
  461. { type: 'value', name: 'ERP', nameTextStyle: { color: C.brandD, fontSize: 10.5, padding: [0, 0, 0, -28] }, position: 'left', ...axisStyle({ axisLabel: { color: C.mut, fontSize: 11, formatter: '{value}%' } }) },
  462. { type: 'value', name: '指数走势', nameTextStyle: { color: C.warn, fontSize: 10.5, padding: [0, -28, 0, 0] }, position: 'right', ...axisStyle({ splitLine: { show: false } }) },
  463. ]
  464. : { type: 'value', ...axisStyle({ axisLabel: { color: C.mut, fontSize: 11, formatter: '{value}%' } }) },
  465. series: [
  466. {
  467. name: 'ERP',
  468. type: 'line',
  469. data: values,
  470. symbol: 'none',
  471. smooth: true,
  472. lineStyle: { color: C.brandD, width: 2.4 },
  473. areaStyle: {
  474. color: {
  475. type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
  476. colorStops: [
  477. { offset: 0, color: 'rgba(14,58,150,0.28)' },
  478. { offset: 1, color: 'rgba(14,58,150,0.02)' },
  479. ],
  480. },
  481. },
  482. // markLine: {
  483. // symbol: ['none', 'none'],
  484. // data: [
  485. // { yAxis: 4.0, lineStyle: { color: C.mut, type: 'dashed', width: 1 }, label: { show: true, position: 'end', formatter: '历史中枢', color: C.mut, fontSize: 10 } },
  486. // ],
  487. // },
  488. markPoint: {
  489. symbol: 'circle', symbolSize: 8,
  490. itemStyle: { color: C.brandD, borderColor: '#fff', borderWidth: 2 },
  491. label: { formatter: (p) => `${p.value.toFixed(2)}%`, color: C.brandD, fontSize: 10.5, position: 'top', fontWeight: 600 },
  492. data: [{ name: '当前', value: values[values.length - 1], xAxis: dates[dates.length - 1], yAxis: values[values.length - 1] }],
  493. },
  494. },
  495. ...(hasClose ? [{
  496. name: '指数走势',
  497. type: 'line',
  498. yAxisIndex: 1,
  499. data: closeAligned,
  500. symbol: 'none',
  501. smooth: true,
  502. lineStyle: { color: C.warn, width: 1.6 },
  503. markPoint: {
  504. symbol: 'circle', symbolSize: 7,
  505. itemStyle: { color: C.warn, borderColor: '#fff', borderWidth: 2 },
  506. label: { formatter: (p) => p.value.toFixed(0), color: C.warn, fontSize: 10, position: 'bottom', fontWeight: 600 },
  507. data: [{ name: '当前指数走势', value: lastClose, xAxis: lastCloseDate, yAxis: lastClose }],
  508. },
  509. }] : []),
  510. ],
  511. }
  512. }
  513. // ===== 换手率 + 成交额 双轴 =====
  514. export function liquidityBarLineOption(series, closeSeries) {
  515. const dates = series.map((s) => s[0])
  516. const turnover = series.map((s) => s[1])
  517. const volume = series.map((s) => s[2])
  518. const hasClose = Array.isArray(closeSeries) && closeSeries.length > 0
  519. const closeMap = hasClose ? new Map(closeSeries.map(([d, v]) => [d, v])) : null
  520. const closeAligned = hasClose ? dates.map((d) => (closeMap.has(d) ? closeMap.get(d) : null)) : []
  521. const lastClose = hasClose ? closeSeries[closeSeries.length - 1][1] : null
  522. const lastCloseDate = hasClose ? closeSeries[closeSeries.length - 1][0] : null
  523. const legendData = [
  524. { name: '换手率', itemStyle: { color: C.brand } },
  525. { name: '成交额(千亿港元)', itemStyle: { color: C.up } },
  526. ]
  527. if (hasClose) legendData.push({ name: '指数走势', itemStyle: { color: C.warn } })
  528. return {
  529. tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'shadow' } },
  530. legend: {
  531. data: legendData,
  532. top: 4,
  533. textStyle: { color: C.mut, fontSize: 11 },
  534. itemWidth: 14,
  535. },
  536. grid: { top: 36, left: 50, right: 60, bottom: 32, containLabel: true },
  537. xAxis: { type: 'category', data: dates, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10.5 } }) },
  538. yAxis: [
  539. { type: 'value', name: '换手率(%)', ...axisStyle({ axisLabel: { color: C.mut, fontSize: 11, formatter: '{value}%' }, nameTextStyle: { color: C.mut, fontSize: 10 } }) },
  540. { type: 'value', name: hasClose ? '成交额(千亿) / 指数走势' : '成交额(千亿)', ...axisStyle({ axisLabel: { color: C.mut, fontSize: 11 }, nameTextStyle: { color: C.mut, fontSize: 10 } }) },
  541. ],
  542. series: [
  543. { name: '换手率', type: 'bar', data: turnover, barWidth: 12, itemStyle: { color: 'rgba(21,82,208,0.65)', borderRadius: [3, 3, 0, 0] } },
  544. { name: '成交额(千亿港元)', type: 'line', yAxisIndex: 1, data: volume, symbol: 'none', smooth: true, lineStyle: { color: C.up, width: 2.2 } },
  545. ...(hasClose ? [{
  546. name: '指数走势',
  547. type: 'line',
  548. yAxisIndex: 1,
  549. data: closeAligned,
  550. symbol: 'none',
  551. smooth: true,
  552. lineStyle: { color: C.warn, width: 1.6 },
  553. markPoint: {
  554. symbol: 'circle', symbolSize: 7,
  555. itemStyle: { color: C.warn, borderColor: '#fff', borderWidth: 2 },
  556. label: { formatter: (p) => p.value.toFixed(0), color: C.warn, fontSize: 10, position: 'bottom', fontWeight: 600 },
  557. data: [{ name: '当前指数走势', value: lastClose, xAxis: lastCloseDate, yAxis: lastClose }],
  558. },
  559. }] : []),
  560. ],
  561. }
  562. }
  563. // ===== RSI 单线 =====
  564. export function rsiLineOption(series, closeSeries) {
  565. const dates = series.map((s) => s[0])
  566. const values = series.map((s) => s[1])
  567. const hasClose = Array.isArray(closeSeries) && closeSeries.length > 0
  568. const closeMap = hasClose ? new Map(closeSeries.map(([d, v]) => [d, v])) : null
  569. const closeAligned = hasClose ? dates.map((d) => (closeMap.has(d) ? closeMap.get(d) : null)) : []
  570. const lastClose = hasClose ? closeSeries[closeSeries.length - 1][1] : null
  571. const lastCloseDate = hasClose ? closeSeries[closeSeries.length - 1][0] : null
  572. const legendData = [{ name: 'RSI', itemStyle: { color: C.brand } }]
  573. if (hasClose) legendData.push({ name: '指数走势', itemStyle: { color: C.warn } })
  574. return {
  575. tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'line' }, valueFormatter: (v) => v.toFixed(2) },
  576. legend: {
  577. data: legendData,
  578. top: 4,
  579. textStyle: { color: C.mut, fontSize: 11 },
  580. itemWidth: 14,
  581. },
  582. grid: { top: 36, left: 50, right: hasClose ? 60 : 24, bottom: 32, containLabel: true },
  583. xAxis: { type: 'category', data: dates, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10.5 } }) },
  584. yAxis: hasClose
  585. ? [
  586. { type: 'value', min: 0, max: 100, interval: 25, ...axisStyle() },
  587. { type: 'value', name: '指数走势', position: 'right', ...axisStyle({ splitLine: { show: false }, nameTextStyle: { color: C.warn, fontSize: 10 } }) },
  588. ]
  589. : { type: 'value', min: 0, max: 100, interval: 25, ...axisStyle() },
  590. series: [
  591. {
  592. name: 'RSI', type: 'line', data: values, symbol: 'none', smooth: true,
  593. lineStyle: { color: C.brand, width: 2.4 },
  594. areaStyle: {
  595. color: {
  596. type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
  597. colorStops: [
  598. { offset: 0, color: 'rgba(21,82,208,0.28)' },
  599. { offset: 1, color: 'rgba(21,82,208,0.02)' },
  600. ],
  601. },
  602. },
  603. markLine: {
  604. symbol: ['none', 'none'],
  605. data: [
  606. { yAxis: 70, lineStyle: { color: C.up, type: 'dashed' }, label: { show: true, position: 'end', formatter: '超买 70', color: C.up, fontSize: 10 } },
  607. { yAxis: 30, lineStyle: { color: C.down, type: 'dashed' }, label: { show: true, position: 'end', formatter: '超卖 30', color: C.down, fontSize: 10 } },
  608. { yAxis: 50, lineStyle: { color: C.mut, type: 'dotted' }, label: { show: true, position: 'end', formatter: '中枢 50', color: C.mut, fontSize: 10 } },
  609. ],
  610. },
  611. markPoint: {
  612. symbol: 'circle', symbolSize: 8,
  613. itemStyle: { color: C.brand, borderColor: '#fff', borderWidth: 2 },
  614. label: { formatter: (p) => p.value.toFixed(2), color: C.brand, fontSize: 10.5, position: 'top', fontWeight: 600 },
  615. data: [{ name: '当前', value: values[values.length - 1], xAxis: dates[dates.length - 1], yAxis: values[values.length - 1] }],
  616. },
  617. },
  618. ...(hasClose ? [{
  619. name: '指数走势',
  620. type: 'line',
  621. yAxisIndex: 1,
  622. data: closeAligned,
  623. symbol: 'none',
  624. smooth: true,
  625. lineStyle: { color: C.warn, width: 1.6 },
  626. markPoint: {
  627. symbol: 'circle', symbolSize: 7,
  628. itemStyle: { color: C.warn, borderColor: '#fff', borderWidth: 2 },
  629. label: { formatter: (p) => p.value.toFixed(0), color: C.warn, fontSize: 10, position: 'bottom', fontWeight: 600 },
  630. data: [{ name: '当前指数走势', value: lastClose, xAxis: lastCloseDate, yAxis: lastClose }],
  631. },
  632. }] : []),
  633. ],
  634. }
  635. }
  636. // ===== VOSC 单线 =====
  637. export function voscLineOption(series, closeSeries) {
  638. const dates = series.map((s) => s[0])
  639. const values = series.map((s) => s[1])
  640. const hasClose = Array.isArray(closeSeries) && closeSeries.length > 0
  641. const closeMap = hasClose ? new Map(closeSeries.map(([d, v]) => [d, v])) : null
  642. const closeAligned = hasClose ? dates.map((d) => (closeMap.has(d) ? closeMap.get(d) : null)) : []
  643. const lastClose = hasClose ? closeSeries[closeSeries.length - 1][1] : null
  644. const lastCloseDate = hasClose ? closeSeries[closeSeries.length - 1][0] : null
  645. const legendData = [{ name: 'VOSC', itemStyle: { color: C.up } }]
  646. if (hasClose) legendData.push({ name: '指数走势', itemStyle: { color: C.warn } })
  647. return {
  648. tooltip: { ...tipOpt, trigger: 'axis', axisPointer: { type: 'line' }, valueFormatter: (v) => v.toFixed(3) },
  649. legend: {
  650. data: legendData,
  651. top: 4,
  652. textStyle: { color: C.mut, fontSize: 11 },
  653. itemWidth: 14,
  654. },
  655. grid: { top: 36, left: 50, right: hasClose ? 60 : 24, bottom: 32, containLabel: true },
  656. xAxis: { type: 'category', data: dates, ...axisStyle({ axisLabel: { color: C.mut, fontSize: 10.5 } }) },
  657. yAxis: hasClose
  658. ? [
  659. { type: 'value', ...axisStyle() },
  660. { type: 'value', name: '指数走势', position: 'right', ...axisStyle({ splitLine: { show: false }, nameTextStyle: { color: C.warn, fontSize: 10 } }) },
  661. ]
  662. : { type: 'value', ...axisStyle() },
  663. series: [
  664. {
  665. name: 'VOSC', type: 'line', data: values, symbol: 'none', smooth: true,
  666. lineStyle: { color: C.up, width: 2.4 },
  667. areaStyle: {
  668. color: {
  669. type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
  670. colorStops: [
  671. { offset: 0, color: 'rgba(225,59,59,0.25)' },
  672. { offset: 1, color: 'rgba(225,59,59,0.02)' },
  673. ],
  674. },
  675. },
  676. // markLine: {
  677. // symbol: ['none', 'none'],
  678. // data: [{ yAxis: 0, lineStyle: { color: C.mut, type: 'dashed' }, label: { show: true, position: 'end', formatter: '零轴', color: C.mut, fontSize: 10 } }],
  679. // },
  680. markPoint: {
  681. symbol: 'circle', symbolSize: 8,
  682. itemStyle: { color: C.up, borderColor: '#fff', borderWidth: 2 },
  683. label: { formatter: (p) => p.value.toFixed(3), color: C.up, fontSize: 10.5, position: 'top', fontWeight: 600 },
  684. data: [{ name: '当前', value: values[values.length - 1], xAxis: dates[dates.length - 1], yAxis: values[values.length - 1] }],
  685. },
  686. },
  687. ...(hasClose ? [{
  688. name: '指数走势',
  689. type: 'line',
  690. yAxisIndex: 1,
  691. data: closeAligned,
  692. symbol: 'none',
  693. smooth: true,
  694. lineStyle: { color: C.warn, width: 1.6 },
  695. markPoint: {
  696. symbol: 'circle', symbolSize: 7,
  697. itemStyle: { color: C.warn, borderColor: '#fff', borderWidth: 2 },
  698. label: { formatter: (p) => p.value.toFixed(0), color: C.warn, fontSize: 10, position: 'bottom', fontWeight: 600 },
  699. data: [{ name: '当前指数走势', value: lastClose, xAxis: lastCloseDate, yAxis: lastClose }],
  700. },
  701. }] : []),
  702. ],
  703. }
  704. }