You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

739 lines
21 KiB

  1. <template>
  2. <div ref="qxnlzhqEchartsRef" id="qxnlzhqEcharts"></div>
  3. </template>
  4. <script setup>
  5. import { ref, onMounted, onBeforeUnmount, toRef, reactive } from "vue";
  6. import * as echarts from "echarts";
  7. defineExpose({ initQXNLZHEcharts });
  8. let qxnlzhqEchartsRef = ref(null);
  9. let qxnlzhqEchartsInstance = null;
  10. let regions = reactive([]);
  11. const dataMax=ref(null)
  12. // 设置区域名称 位置
  13. function getNameTop(min, max, regionMiidle) {
  14. // 获取整个图表的高度
  15. const chartHeight = qxnlzhqEchartsInstance.getHeight();
  16. // 60: 为x轴占的高度
  17. return (max - Number(regionMiidle)) / (max - min) * (chartHeight - 60)
  18. }
  19. // 设置区域最大值 位置
  20. function getNumberTop(min, max, regionMax) {
  21. // 获取整个图表的高度
  22. const chartHeight = qxnlzhqEchartsInstance.getHeight();
  23. // 60: 为x轴占的高度
  24. return (max - Number(regionMax)) / (max - min) * (chartHeight - 60)
  25. }
  26. // 生成图形标注(核心逻辑)
  27. const generateGraphics = (min, max) => {
  28. let hasPartialVisible = false; // 标记是否已经遇到第一个部分可见的区域
  29. return regions.flatMap((region) => {
  30. if(!region.min || !region.max) return [];
  31. const middleY = (Number(region.min) + Number(region.max)) / 2;
  32. const safeY = Math.max(min, Math.min(middleY, max*0.99));
  33. // 检查区域是否完全可见
  34. const isFullyVisible = region.min >= min && region.max <= max;
  35. // 检查区域是否部分可见
  36. const isPartiallyVisible = (region.min < max && region.max > min) && !isFullyVisible;
  37. // 如果已经有一个部分可见的区域名称显示了,就不再显示其他部分可见的区域名称
  38. if (isPartiallyVisible && hasPartialVisible) {
  39. return [];
  40. }
  41. // 如果是第一个部分可见的区域,设置标记
  42. if (isPartiallyVisible) {
  43. hasPartialVisible = true;
  44. }
  45. const graphics = [];
  46. // 区域名称(中间位置)
  47. if (isFullyVisible || isPartiallyVisible) {
  48. graphics.push({
  49. type: "text",
  50. left: '10%',
  51. top: getNameTop(min, max, safeY),
  52. style: {
  53. text: region.name,
  54. fill: region.fontColor,
  55. fontSize: 14,
  56. fontWeight: "bold",
  57. },
  58. z: 3,
  59. });
  60. }
  61. // y轴数值(顶部位置)
  62. if (isFullyVisible) {
  63. graphics.push({
  64. type: "text",
  65. left: '5%', // 向右调整位置
  66. top: getNumberTop(min, max, region.max),
  67. // top: 100,
  68. style: {
  69. text: region.max.toString(),
  70. fill: region.NumberColor,
  71. fontSize: 12,
  72. },
  73. z: 3,
  74. });
  75. }
  76. return graphics;
  77. });
  78. };
  79. function initQXNLZHEcharts(kline, qxnlzhqData) {
  80. // 测试数据 !!! 删掉
  81. // qxnlzhqData.topxh = ["2025/04/04", "2025/04/15"]
  82. // qxnlzhqData.lowxh = ["2025/04/08", "2025/04/18"]
  83. // qxnlzhqData.qixh = ["2025/04/10", "2025/04/21"]
  84. if (qxnlzhqEchartsInstance) {
  85. qxnlzhqEchartsInstance.dispose();
  86. }
  87. // 数据
  88. let mixData = [];
  89. kline.forEach((element) => {
  90. let date = element[0];
  91. let value = [element[1], element[2], element[3], element[4]];
  92. mixData.push({
  93. date,
  94. value,
  95. });
  96. });
  97. // 动态区域配置
  98. // dd到zc 低吸区------情绪冰点区 ; zc到ht 关注区------认知潜伏区; ht到qs 回调区------多空消化区 ; qs到tp 拉升区------共识加速区;
  99. // tp到js 突破区------情绪临界区 ; js到yl 警示区-------杠杆失衡区 ; yl到gg 风险区-------情绪熔断区;
  100. regions = [
  101. {
  102. min: qxnlzhqData.dd,
  103. max: qxnlzhqData.zc,
  104. name: "【情绪冰点区】",
  105. color: "#FF9F9F",
  106. fontColor: '#666666',
  107. NumberColor: 'white',
  108. },
  109. {
  110. min: qxnlzhqData.zc,
  111. max: qxnlzhqData.ht,
  112. name: "【认知潜伏区】",
  113. color: "#FFCB75",
  114. fontColor: '#666666',
  115. NumberColor: 'white',
  116. },
  117. {
  118. min: qxnlzhqData.ht,
  119. max: qxnlzhqData.qs,
  120. name: "【多空消化区】",
  121. color: "#D7E95D",
  122. fontColor: '#666666',
  123. NumberColor: 'white',
  124. },
  125. {
  126. min: qxnlzhqData.qs,
  127. max: qxnlzhqData.tp,
  128. name: "【共识加速区】",
  129. color: "#A0F56F",
  130. fontColor: '#666666',
  131. NumberColor: 'white',
  132. },
  133. {
  134. min: qxnlzhqData.tp,
  135. max: qxnlzhqData.js,
  136. name: "【情绪临界区】",
  137. color: "#87F3CD",
  138. fontColor: '#666666',
  139. NumberColor: 'white',
  140. },
  141. ];
  142. // gg yl为-1 不绘制部分图表
  143. if (Number(qxnlzhqData.yl) != -1) {
  144. regions.push(
  145. {
  146. min: qxnlzhqData.js,
  147. max: qxnlzhqData.yl,
  148. name: "【杠杆失衡区】",
  149. color: "#51C3F9",
  150. fontColor: '#666666',
  151. NumberColor: 'white',
  152. },
  153. )
  154. }
  155. if (Number(qxnlzhqData.gg) != -1) {
  156. regions.push(
  157. {
  158. min: qxnlzhqData.yl,
  159. max: qxnlzhqData.gg,
  160. name: "【情绪熔断区】",
  161. color: "#D0A7FF",
  162. fontColor: '#666666',
  163. NumberColor: 'white',
  164. },
  165. )
  166. }
  167. // 计算动态的y轴范围
  168. const priceValues = kline.flatMap(item => [item[1], item[2], item[3], item[4]]);
  169. const dataMin = Math.min(...priceValues);
  170. const dataMax = Math.max(...priceValues);
  171. // 获取最后一根K线数据
  172. const lastKLine = mixData[mixData.length - 1];
  173. const lastHigh = lastKLine.value[2]; // 最高价
  174. const lastLow = lastKLine.value[3]; // 最低价
  175. // 计算止盈止损价格
  176. const stopProfitPrice = Number(qxnlzhqData.cc) * 1.05; // 止盈价
  177. const stopLossPrice = Number(qxnlzhqData.cc) * 0.97; // 止损价
  178. // 确定起始和结束位置
  179. const startIndex = Math.max(0, mixData.length - 17);
  180. // 创建完整数据数组
  181. const takeProfitData = new Array(mixData.length).fill(null);
  182. const stopLossData = new Array(mixData.length).fill(null);
  183. // 填充显示区域的数据
  184. for (var i = startIndex; i < mixData.length; i++) {
  185. takeProfitData[i] = stopProfitPrice;
  186. stopLossData[i] = stopLossPrice;
  187. }
  188. // topxh、lowxh、qixh 对应k线染色
  189. // 创建中间区域数据
  190. const middleRangeData = [];
  191. const middleRangeData1 = [];
  192. const markPointData = [];
  193. mixData.forEach((item, index) => {
  194. const [open, close, low, high] = item.value;
  195. const rangeHeight = high - low;
  196. // const middleThirdStart = low + rangeHeight * (1/3);
  197. // const middleThirdEnd = low + rangeHeight * (2/3);
  198. let color = null;
  199. if (qxnlzhqData.topxh.includes(item.date)) {
  200. color = '#000000'; // 黑色
  201. } else if (qxnlzhqData.lowxh.includes(item.date)) {
  202. color = '#1E90FF'; // 蓝色
  203. }
  204. // 添加中间区域数据
  205. if (color) {
  206. middleRangeData.push({
  207. value: [index, close > open ? (close - open) : (open - close)], // 修正数据格式
  208. itemStyle: {
  209. normal: {
  210. color: color
  211. }
  212. },
  213. });
  214. middleRangeData1.push({
  215. value: [index, close > open ? open : close], // 修正数据格式
  216. itemStyle: {
  217. normal: {
  218. color: 'transparent'
  219. }
  220. },
  221. });
  222. } else {
  223. middleRangeData.push(null);
  224. middleRangeData1.push(null);
  225. }
  226. // 添加文字标记数据
  227. if (qxnlzhqData.qixh.includes(item.date)) {
  228. markPointData.push({
  229. name: '起',
  230. coord: [index, (open + close) / 2],
  231. itemStyle: {
  232. normal: {
  233. color: 'rgba(0,0,0,0)' // 标记点透明
  234. }
  235. },
  236. label: {
  237. normal: {
  238. show: true,
  239. position: 'inside',
  240. formatter: '起',
  241. textStyle: {
  242. color: '#FF0000',
  243. fontSize: 10,
  244. // fontWeight: 'bold'
  245. }
  246. }
  247. }
  248. });
  249. }
  250. });
  251. // 初始化图表
  252. qxnlzhqEchartsInstance = echarts.init(qxnlzhqEchartsRef.value);
  253. let option;
  254. // 设置图表配置
  255. option = {
  256. tooltip: {
  257. show: true,
  258. trigger: 'axis',
  259. axisPointer: {
  260. type: 'line',
  261. lineStyle: {
  262. color: '#fff',
  263. width: 1,
  264. type: 'solid'
  265. },
  266. label: {
  267. backgroundColor: 'rgba(0, 0, 0, 0.8)',
  268. color: '#fff',
  269. borderColor: '#fff',
  270. borderWidth: 1
  271. }
  272. },
  273. backgroundColor: '#646E71',
  274. borderColor: '#fff',
  275. borderWidth: 1,
  276. padding: 10,
  277. textStyle: {
  278. color: '#fff',
  279. fontSize: 12
  280. },
  281. formatter: function (params) {
  282. if (!params || params.length === 0) return ''
  283. let result = `<div style="font-weight: bold; color: #fff; margin-bottom: 8px;">${params[0].name}</div>`
  284. params.forEach(param => {
  285. let value = param.value
  286. let color = param.color
  287. if (param.seriesType === 'candlestick') {
  288. let openPrice = value[1] // 开盘价
  289. let closePrice = value[2] // 收盘价
  290. let lowPrice = value[3] // 最低价
  291. let highPrice = value[4] // 最高价
  292. // 检查数据有效性
  293. if (typeof openPrice !== 'number' || typeof closePrice !== 'number' ||
  294. typeof lowPrice !== 'number' || typeof highPrice !== 'number') {
  295. return ''
  296. }
  297. let priceChange = closePrice - openPrice
  298. let changePercent = ((priceChange / openPrice) * 100).toFixed(2)
  299. let changeColor = priceChange >= 0 ? '#14b143' : '#ef232a'
  300. result += `<div style="margin-bottom: 6px;">`
  301. // result += `<div style="color: #fff; font-weight: bold;">${param.seriesName}</div>`
  302. result += `<div style="color: #fff;">开盘: ${openPrice.toFixed(2)}</div>`
  303. result += `<div style="color: #fff;">收盘: ${closePrice.toFixed(2)}</div>`
  304. result += `<div style="color: #fff;">最低: ${lowPrice.toFixed(2)}</div>`
  305. result += `<div style="color: #fff;">最高: ${highPrice.toFixed(2)}</div>`
  306. result += `<div style="color: ${changeColor};">涨跌: ${priceChange >= 0 ? '+' : ''}${priceChange.toFixed(2)} (${changePercent}%)</div>`
  307. result += `</div>`
  308. } else if (param.seriesName === '止盈线' && value !== null && value !== undefined && typeof value === 'number') {
  309. result += `<div style="color: #FF0000; margin-bottom: 4px;">${param.seriesName}: ${value.toFixed(2)}</div>`
  310. } else if (param.seriesName === '止损线' && value !== null && value !== undefined && typeof value === 'number') {
  311. result += `<div style="color: #001EFF; margin-bottom: 4px;">${param.seriesName}: ${value.toFixed(2)}</div>`
  312. }
  313. })
  314. return result
  315. }
  316. },
  317. dataZoom: [
  318. {
  319. type: 'slider',
  320. xAxisIndex: 0,
  321. start: 0,
  322. end: 100,
  323. show: true,
  324. bottom: 10,
  325. height: 20,
  326. borderColor: '#fff',
  327. fillerColor: 'rgba(255, 255, 255, 0.2)',
  328. handleStyle: {
  329. color: '#fff',
  330. borderColor: '#fff'
  331. },
  332. textStyle: {
  333. color: '#fff'
  334. }
  335. },
  336. {
  337. type: 'inside',
  338. xAxisIndex: 0,
  339. start: 0,
  340. end: 100,
  341. zoomOnMouseWheel: true,
  342. moveOnMouseMove: true,
  343. moveOnMouseWheel: false
  344. }
  345. ],
  346. xAxis: {
  347. type: "category",
  348. data: mixData.map((item) => item.date),
  349. axisLabel: {
  350. rotate: 0, // 取消倾斜角度
  351. color: "white",
  352. interval: 'auto' // 自动计算显示间隔,只显示部分日期但覆盖所有范围
  353. },
  354. axisLine: {
  355. // show: false,
  356. lineStyle: {
  357. color: 'white', // x轴线颜色
  358. }
  359. },
  360. },
  361. yAxis: {
  362. scale: true,
  363. axisLine: {
  364. // show: false,
  365. lineStyle: {
  366. color: 'white', // x轴线颜色
  367. }
  368. },
  369. splitLine: {
  370. show: false,
  371. },
  372. axisLabel: { // 刻度标签
  373. show: false // 不显示刻度标签
  374. },
  375. axisTick: { // 刻度线
  376. show: false // 不显示刻度线
  377. },
  378. min:
  379. qxnlzhqData.dd < stopLossPrice * 0.98
  380. ? Math.floor(qxnlzhqData.dd)
  381. : Math.floor(stopLossPrice * 0.98),
  382. max: Math.max(Math.ceil(dataMax * 1.02), (qxnlzhqData.yl > 0 ? qxnlzhqData.yl : Math.ceil(dataMax * 1.02)), stopProfitPrice * 1.02),
  383. },
  384. // 自定义区域名称和区域范围值 位置
  385. graphic: generateGraphics(qxnlzhqData.dd < stopLossPrice * 0.98
  386. ? Math.floor(qxnlzhqData.dd)
  387. : Math.floor(stopLossPrice * 0.98), Math.max(Math.ceil(dataMax * 1.02), (qxnlzhqData.yl > 0 ? qxnlzhqData.yl : Math.ceil(dataMax * 1.02)), stopProfitPrice * 1.02),),
  388. series: [
  389. {
  390. type: "candlestick",
  391. data: mixData.map((item) => item.value),
  392. z: 1,
  393. clip: true,
  394. markPoint: {
  395. symbol: 'circle',
  396. symbolSize: 10,
  397. data: markPointData,
  398. z: 5 // 确保标记显示在最上层
  399. },
  400. itemStyle: {
  401. normal: {
  402. // 阳线样式(收盘 > 开盘)
  403. // color: '#14b143', // 开盘价 < 收盘价时为绿色
  404. color: 'rgba(0,0,0,0)',
  405. color0: '#ef232a', // 开盘价 > 收盘价时为红色
  406. borderColor: '#14b143', // 阳线边框色(绿)
  407. borderColor0: '#ef232a', // 阴线边框色(红)
  408. borderWidth: 1.5
  409. }
  410. },
  411. // 实现 分区域背景色
  412. markArea: {
  413. silent: true,
  414. data: regions.map((region) => [
  415. {
  416. yAxis: region.min,
  417. itemStyle: { normal: { color: region.color } },
  418. },
  419. { yAxis: region.max },
  420. ]),
  421. },
  422. },
  423. {
  424. name: '中间区域',
  425. type: 'bar',
  426. stack: 'total',
  427. data: middleRangeData1,
  428. barWidth: '20%',
  429. barCategoryGap: '40%',
  430. itemStyle: {
  431. normal: {
  432. color: 'rgba(0,0,0,0)' // 默认透明
  433. }
  434. },
  435. z: 2
  436. },
  437. // 中间区域染色
  438. {
  439. name: '中间区域',
  440. type: 'bar',
  441. stack: 'total',
  442. data: middleRangeData,
  443. barWidth: '20%',
  444. barCategoryGap: '40%',
  445. itemStyle: {
  446. normal: {
  447. color: 'rgba(0,0,0,0)' // 默认透明
  448. }
  449. },
  450. z: 2
  451. },
  452. {
  453. name: '止盈线',
  454. type: 'line',
  455. data: takeProfitData,
  456. symbol: 'none',
  457. lineStyle: {
  458. normal: {
  459. color: '#FF0000', // 蓝色
  460. width: 2,
  461. type: 'solid'
  462. }
  463. },
  464. markPoint: {
  465. symbol: 'circle',
  466. symbolSize: 1,
  467. data: [
  468. {
  469. coord: [mixData.map((item) => item.value).length - 1, stopProfitPrice],
  470. itemStyle: {
  471. color: '#ff80ff'
  472. },
  473. label: {
  474. normal: {
  475. show: true,
  476. position: 'top',
  477. formatter: `{text|止盈}`,
  478. rich: {
  479. text: {
  480. color: '#FF0000',
  481. fontSize: 14,
  482. fontWeight: 'bold'
  483. }
  484. },
  485. offset: [-20, 0]
  486. }
  487. }
  488. }
  489. ]
  490. }
  491. },
  492. {
  493. name: '止损线',
  494. type: 'line',
  495. data: stopLossData,
  496. symbol: 'none',
  497. lineStyle: {
  498. normal: {
  499. color: '#001EFF',
  500. width: 2,
  501. type: 'solid'
  502. }
  503. },
  504. markPoint: {
  505. symbol: 'circle',
  506. symbolSize: 1,
  507. data: [
  508. {
  509. coord: [mixData.map((item) => item.value).length - 1, stopLossPrice],
  510. itemStyle: {
  511. color: '#080bfd'
  512. },
  513. label: {
  514. normal: {
  515. show: true,
  516. position: 'bottom',
  517. formatter: `{text|止损}`,
  518. rich: {
  519. text: {
  520. color: '#001EFF',
  521. fontSize: 14,
  522. fontWeight: 'bold'
  523. }
  524. },
  525. offset: [-20, 0]
  526. }
  527. }
  528. }
  529. ]
  530. }
  531. },
  532. {
  533. name: '最低价',
  534. type: 'line',
  535. symbol: 'none',
  536. lineStyle: {
  537. normal: {
  538. color: 'transparent',
  539. width: 0
  540. }
  541. },
  542. markPoint: {
  543. symbol: 'circle',
  544. symbolSize: 1,
  545. data: [
  546. {
  547. coord: [mixData.length - 1, mixData[mixData.length - 1].value[2]],
  548. itemStyle: {
  549. color: 'transparent'
  550. },
  551. label: {
  552. normal: {
  553. show: true,
  554. position: 'top',
  555. formatter: `{text|${mixData[mixData.length - 1].value[2].toFixed(2)}}`,
  556. rich: {
  557. text: {
  558. color: '#001EFF',
  559. fontSize: 12,
  560. fontWeight: 'bold',
  561. textBorderColor: '#ffffff',
  562. textBorderWidth: 2,
  563. }
  564. },
  565. offset: [20, 10]
  566. }
  567. }
  568. }
  569. ]
  570. }
  571. },
  572. {
  573. name: '最高价',
  574. type: 'line',
  575. symbol: 'none',
  576. lineStyle: {
  577. normal: {
  578. color: 'transparent',
  579. width: 0
  580. }
  581. },
  582. markPoint: {
  583. symbol: 'circle',
  584. symbolSize: 1,
  585. data: [
  586. {
  587. coord: [mixData.length - 1, mixData[mixData.length - 1].value[3]],
  588. itemStyle: {
  589. color: 'transparent'
  590. },
  591. label: {
  592. normal: {
  593. show: true,
  594. position: 'bottom',
  595. formatter: `{text|${mixData[mixData.length - 1].value[3].toFixed(2)}}`,
  596. rich: {
  597. text: {
  598. color: '#FF0000',
  599. fontSize: 12,
  600. fontWeight: 'bold',
  601. textBorderColor: '#ffffff',
  602. textBorderWidth: 2,
  603. }
  604. },
  605. offset: [20, -10]
  606. }
  607. }
  608. }
  609. ]
  610. }
  611. }
  612. ],
  613. grid: {
  614. left: "10%",
  615. right: "10",
  616. top: '10',
  617. bottom: "60",
  618. containLabel: false,
  619. width: '85%',
  620. height: 'auto',
  621. overflow: 'hidden'
  622. },
  623. };
  624. // 应用配置
  625. qxnlzhqEchartsInstance.setOption(option);
  626. // 防抖函数,避免频繁触发resize
  627. const debounce = (func, wait) => {
  628. let timeout;
  629. return function executedFunction(...args) {
  630. const later = () => {
  631. clearTimeout(timeout);
  632. func(...args);
  633. };
  634. clearTimeout(timeout);
  635. timeout = setTimeout(later, wait);
  636. };
  637. };
  638. // 监听窗口大小变化,调整图表尺寸
  639. const resizeHandler = debounce(() => {
  640. if (qxnlzhqEchartsInstance && !qxnlzhqEchartsInstance.isDisposed()) {
  641. try {
  642. qxnlzhqEchartsInstance.resize();
  643. console.log('情绪能量转化器图表已重新调整大小');
  644. } catch (error) {
  645. console.error('情绪能量转化器图表resize失败:', error);
  646. }
  647. }
  648. }, 100); // 100ms防抖延迟
  649. // 移除之前的监听器(如果存在)
  650. if (window.emoEnergyConverterResizeHandler) {
  651. window.removeEventListener('resize', window.emoEnergyConverterResizeHandler);
  652. }
  653. // 添加新的监听器
  654. window.addEventListener('resize', resizeHandler);
  655. // 存储resize处理器以便后续清理
  656. window.emoEnergyConverterResizeHandler = resizeHandler;
  657. // 添加容器大小监听器
  658. if (qxnlzhqEchartsRef.value && window.ResizeObserver) {
  659. const containerObserver = new ResizeObserver(debounce(() => {
  660. if (qxnlzhqEchartsInstance && !qxnlzhqEchartsInstance.isDisposed()) {
  661. try {
  662. qxnlzhqEchartsInstance.resize();
  663. console.log('情绪能量转化器容器大小变化,图表已调整');
  664. } catch (error) {
  665. console.error('情绪能量转化器容器resize失败:', error);
  666. }
  667. }
  668. }, 100));
  669. containerObserver.observe(qxnlzhqEchartsRef.value);
  670. window.emoEnergyConverterContainerObserver = containerObserver;
  671. }
  672. }
  673. onBeforeUnmount(() => {
  674. // 组件卸载时销毁图表
  675. if (qxnlzhqEchartsInstance) {
  676. qxnlzhqEchartsInstance.dispose();
  677. qxnlzhqEchartsInstance = null;
  678. }
  679. // 移除窗口resize监听器
  680. if (window.emoEnergyConverterResizeHandler) {
  681. window.removeEventListener('resize', window.emoEnergyConverterResizeHandler);
  682. window.emoEnergyConverterResizeHandler = null;
  683. }
  684. // 清理容器观察器
  685. if (window.emoEnergyConverterContainerObserver) {
  686. window.emoEnergyConverterContainerObserver.disconnect();
  687. window.emoEnergyConverterContainerObserver = null;
  688. }
  689. });
  690. </script>
  691. <style scoped>
  692. #qxnlzhqEcharts {
  693. width: 100%;
  694. height: 700px;
  695. margin: 0;
  696. box-sizing: border-box;
  697. overflow: hidden;
  698. }
  699. /* 手机端适配样式 */
  700. @media only screen and (max-width: 768px) {
  701. #qxnlzhqEcharts {
  702. width: 100%;
  703. height: 300px;
  704. margin: 0;
  705. }
  706. }
  707. </style>