index.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. <template>
  2. <div class="popularity-prediction">
  3. <div class="tree-map-container">
  4. <!-- 筛选条件 -->
  5. <el-card class="filter-card">
  6. <el-form :inline="true" :model="form" size="small">
  7. <el-form-item label="病原体名称">
  8. <el-input
  9. v-model="form.bytName"
  10. placeholder="请输入病原体名称"
  11. style="width: 180px"
  12. @keyup.enter="initData"
  13. />
  14. </el-form-item>
  15. <el-form-item label="时间">
  16. <el-date-picker
  17. v-model="form.dateRange"
  18. type="daterange"
  19. range-separator="至"
  20. start-placeholder="开始日期"
  21. end-placeholder="结束日期"
  22. style="width: 240px"
  23. value-format="yyyy-MM-dd"
  24. />
  25. </el-form-item>
  26. <el-form-item>
  27. <el-button type="primary" @click="initData">查询</el-button>
  28. </el-form-item>
  29. </el-form>
  30. </el-card>
  31. <!-- <div class="phylo-tree">
  32. <div id="phyloTree" ref="treeContainer" style="width: 100%; height: 500px;"></div>
  33. </div> -->
  34. <el-row :gutter="20">
  35. <el-col :span="12">
  36. <div class="world-map">
  37. <h3>世界地图病毒预测图</h3>
  38. <div id="worldVirusMap" style="width: 100%; height: 500px;"></div>
  39. </div>
  40. </el-col>
  41. <el-col :span="12">
  42. <div class="china-map">
  43. <h3>中国地图病毒预测图</h3>
  44. <div id="chinaVirusMap" style="width: 100%; height: 500px;"></div>
  45. </div>
  46. </el-col>
  47. </el-row>
  48. </div>
  49. </div>
  50. </template>
  51. <script>
  52. import * as echarts from 'echarts';
  53. import worldJson from 'echarts/map/json/world.json';
  54. import chinaJson from 'echarts/map/json/china.json';
  55. import { generatesjbytfzzbqkInfo, generatezgbytfzzbqkInfo } from '@/api/statistics/report';
  56. echarts.registerMap('world', worldJson);
  57. echarts.registerMap('china', chinaJson);
  58. export default {
  59. name: 'PopularityPrediction',
  60. data() {
  61. return {
  62. form: {
  63. bytName: "",
  64. endDate: "",
  65. startDate: "",
  66. dateRange: []
  67. },
  68. worldData: [],
  69. chinaData: [],
  70. worldMapChart: null,
  71. chinaMapChart: null
  72. };
  73. },
  74. methods: {
  75. renderWorldVirusMap() {
  76. if (!this.worldMapChart) {
  77. this.worldMapChart = echarts.init(document.getElementById('worldVirusMap'));
  78. }
  79. this.renderMap(this.worldMapChart, this.worldData, 'world');
  80. },
  81. renderChinaVirusMap() {
  82. if (!this.chinaMapChart) {
  83. this.chinaMapChart = echarts.init(document.getElementById('chinaVirusMap'));
  84. }
  85. this.renderMap(this.chinaMapChart, this.chinaData, 'china');
  86. },
  87. renderMap(chart, data, mapType) {
  88. // 处理数据,生成区域数据,保留每个病原体的数量信息
  89. const regionData = data.map(item => {
  90. const pathogenData = {};
  91. let totalValue = 0;
  92. Object.keys(item).forEach(key => {
  93. if (key!== 'area' && key!== 'eng') {
  94. const value = item[key];
  95. if (!isNaN(value) && value!== null && value!== undefined) {
  96. pathogenData[key] = value;
  97. totalValue += value;
  98. }
  99. }
  100. });
  101. return {
  102. name: item.eng || item.area || '未知区域',
  103. value: isNaN(totalValue)? 0 : totalValue,
  104. cn: item.area || '未知区域',
  105. pathogenData: pathogenData
  106. };
  107. });
  108. // 校验数据,避免 min 或 max 为 NaN
  109. let visualMapMin = 0;
  110. let visualMapMax = 0;
  111. if (regionData.length > 0) {
  112. const values = regionData.map(item => item.value).filter(value =>!isNaN(value));
  113. if (values.length > 0) {
  114. visualMapMin = Math.min(...values);
  115. visualMapMax = Math.max(...values);
  116. }
  117. }
  118. // 清空旧的配置
  119. chart.clear();
  120. chart.setOption({
  121. title: { text: mapType === 'world' ? '全球病毒预测分布' : '中国病毒预测分布', left: 'center' },
  122. tooltip: {
  123. trigger: 'item',
  124. formatter: (params) => {
  125. const name = params.data && params.data.cn? params.data.cn : '未知区域';
  126. const value = params.data &&!isNaN(params.data.value)? params.data.value : 0;
  127. const pathogenData = params.data && params.data.pathogenData? params.data.pathogenData : {};
  128. let tooltipStr = `<div>${name}</div><div>总数: ${value}</div>`;
  129. Object.keys(pathogenData).forEach(pathogen => {
  130. tooltipStr += `<div>${pathogen}: ${pathogenData[pathogen]}</div>`;
  131. });
  132. return tooltipStr;
  133. }
  134. },
  135. // geo: {
  136. // map: mapType,
  137. // roam: true,
  138. // label: {
  139. // emphasis: {
  140. // show: false
  141. // }
  142. // },
  143. // itemStyle: {
  144. // normal: {
  145. // areaColor: '#f0f0f0',
  146. // borderColor: '#999'
  147. // },
  148. // emphasis: {
  149. // areaColor: '#e0e0e0'
  150. // }
  151. // },
  152. // center: mapType === 'china' ? [104.1954, 35.8617] : [10, 10],
  153. // zoom: mapType === 'china' ? 2 : 1.2
  154. // },
  155. series: [
  156. {
  157. type: 'map',
  158. map: mapType,
  159. // coordinateSystem: 'geo',
  160. data: regionData,
  161. label: {
  162. show: false
  163. },
  164. itemStyle: {
  165. normal: {
  166. areaColor: '#f0f0f0',
  167. borderColor: '#999'
  168. },
  169. emphasis: {
  170. areaColor: '#e0e0e0',
  171. borderWidth: 2,
  172. borderColor: '#fff'
  173. }
  174. }
  175. }
  176. ],
  177. visualMap: {
  178. min: visualMapMin,
  179. max: visualMapMax,
  180. left: 'left',
  181. top: 'bottom',
  182. text: ['高', '低'],
  183. calculable: true,
  184. // inRange: {
  185. // color: ['#ffeeee', '#ffcccc', '#ff9999', '#ff6666', '#ff3333']
  186. // }
  187. }
  188. });
  189. },
  190. renderPhyloTree() {
  191. const strainColors = {
  192. A: '#5470C6',
  193. B: '#91CC75',
  194. C: '#FAC858',
  195. D: '#EE6666',
  196. E: '#73C0DE',
  197. F: '#3BA272',
  198. G: '#FC8452',
  199. H: '#9A60B4',
  200. I: '#EA7CCC',
  201. J: '#FFA500'
  202. };
  203. const strains = Object.keys(strainColors);
  204. // 生成树结构
  205. function createExampleTree(rootStrain, rootYear, rootEndYear, depth = 0, maxDepth = 5, usedSet = new Set()) {
  206. if (depth > maxDepth) return null;
  207. const key = `${rootStrain}_${rootYear}`;
  208. if (usedSet.has(key)) return null; // 跳过重复
  209. usedSet.add(key);
  210. const node = {
  211. name: `病毒${rootStrain}`,
  212. strain: rootStrain,
  213. year: rootYear,
  214. endYear: rootEndYear,
  215. children: []
  216. };
  217. if (depth < maxDepth) {
  218. const childCount = Math.floor(Math.random() * 2) + 1;
  219. for (let i = 0; i < childCount; i++) {
  220. const nextStrain = strains[(strains.indexOf(rootStrain) + i + 1) % strains.length];
  221. const childYear = Math.min(rootEndYear - 1, rootYear + 1 + Math.floor(Math.random() * 3));
  222. const childEndYear = Math.min(rootEndYear, childYear + 3 + Math.floor(Math.random() * 6));
  223. const child = createExampleTree(nextStrain, childYear, childEndYear, depth + 1, maxDepth, usedSet);
  224. if (child) node.children.push(child);
  225. }
  226. }
  227. return node;
  228. }
  229. // 生成5条主干
  230. const roots = [];
  231. for (let i = 0; i < 5; i++) {
  232. const strain = strains[i % strains.length];
  233. const startYear = 2010 + Math.floor(Math.random() * 3);
  234. const endYear = startYear + 8 + Math.floor(Math.random() * 5);
  235. roots.push(createExampleTree(strain, startYear, endYear, 0, 5, new Set()));
  236. }
  237. // 展平树结构,分配y坐标
  238. let yIndex = 0;
  239. const lines = [];
  240. function flatten(node, parentY = null, parentName = null) {
  241. const myY = yIndex++;
  242. lines.push({
  243. name: `${node.name}(${node.year}~${node.endYear})`,
  244. strain: node.strain,
  245. year: node.year,
  246. endYear: node.endYear,
  247. y: myY,
  248. parentName // 记录父节点名称
  249. });
  250. if (node.children && node.children.length) {
  251. node.children.forEach(child => flatten(child, myY, `${node.name}(${node.year}~${node.endYear})`));
  252. }
  253. }
  254. // 主干之间插入空行,增加间距
  255. roots.forEach((root, idx) => {
  256. flatten(root, null, null);
  257. if (idx !== roots.length - 1) {
  258. // 每两个主干之间插入2个空行
  259. yIndex += 3;
  260. }
  261. });
  262. // 图例
  263. const legendData = strains.map(k => `病毒${k}`);
  264. const chart = echarts.init(this.$refs.treeContainer);
  265. chart.setOption({
  266. title: { text: '病毒进化树(横坐标为年份)', left: 'center' },
  267. tooltip: {
  268. trigger: 'item',
  269. formatter: params => params.data ? params.data.name : params.name
  270. },
  271. legend: {
  272. data: legendData,
  273. orient: 'vertical',
  274. right: 10,
  275. top: 60,
  276. formatter: name => `{a|●} {b|${name}}`,
  277. textStyle: {
  278. rich: {
  279. a: { color: (params) => params, fontSize: 18 },
  280. b: { color: '#333', fontSize: 14 }
  281. }
  282. }
  283. },
  284. grid: { left: 80, right: 120, top: 60, bottom: 40 },
  285. xAxis: {
  286. type: 'value',
  287. min: 2010,
  288. max: 2025,
  289. interval: 1,
  290. name: '年份',
  291. axisLabel: { formatter: '{value}' }
  292. },
  293. yAxis: {
  294. type: 'category',
  295. data: lines.map(l => l.name),
  296. show: false,
  297. axisLabel: { show: false },
  298. splitLine: { show: false }
  299. },
  300. series: [
  301. // 横线
  302. {
  303. type: 'custom',
  304. renderItem: function(params, api) {
  305. const y = api.coord([0, params.dataIndex])[1];
  306. const x1 = api.coord([lines[params.dataIndex].year, params.dataIndex])[0];
  307. const x2 = api.coord([lines[params.dataIndex].endYear, params.dataIndex])[0];
  308. const parentY = (() => {
  309. // 查找父节点的y坐标
  310. const parentName = lines[params.dataIndex].parentName;
  311. if (!parentName) return null;
  312. const parentIdx = lines.findIndex(l => l.name === parentName);
  313. if (parentIdx === -1) return null;
  314. return api.coord([lines[params.dataIndex].year, parentIdx])[1];
  315. })();
  316. const children = [];
  317. // 折线:所有节点都加竖线
  318. let startX;
  319. let endX = x1;
  320. let startY = y;
  321. if (parentY !== null && parentY !== y) {
  322. // 分支节点:父节点到当前节点y
  323. startX = x1;
  324. startY = parentY;
  325. } else {
  326. // 主干节点:从x轴(y轴0刻度线)到当前节点
  327. startX = x1;
  328. // 获取x轴像素y坐标
  329. const xAxisY = api.coord([lines[params.dataIndex].year, -0.8])[1];
  330. startY = xAxisY;
  331. }
  332. children.push(
  333. {
  334. type: 'line',
  335. shape: { x1: startX, y1: startY, x2: endX, y2: y },
  336. style: {
  337. stroke: strainColors[lines[params.dataIndex].strain],
  338. lineWidth: 4
  339. }
  340. }
  341. );
  342. // 横线:当前节点生命周期
  343. children.push({
  344. type: 'line',
  345. shape: { x1, y1: y, x2, y2: y },
  346. style: {
  347. stroke: strainColors[lines[params.dataIndex].strain],
  348. lineWidth: 4
  349. }
  350. });
  351. // 终点圆点
  352. children.push({
  353. type: 'circle',
  354. shape: { cx: x2, cy: y, r: 7 },
  355. style: {
  356. fill: strainColors[lines[params.dataIndex].strain],
  357. stroke: '#fff',
  358. lineWidth: 2
  359. }
  360. });
  361. return { type: 'group', children };
  362. },
  363. data: lines,
  364. encode: { x: [ 'year', 'endYear' ], y: 'y' }
  365. }
  366. ]
  367. });
  368. },
  369. async initData() {
  370. if (this.form.dateRange.length > 0) {
  371. const [startDate, endDate] = this.form.dateRange;
  372. this.form.startDate = startDate;
  373. this.form.endDate = endDate;
  374. }
  375. const res = await generatesjbytfzzbqkInfo(this.form);
  376. const res2 = await generatezgbytfzzbqkInfo(this.form);
  377. this.worldData = res.data;
  378. this.chinaData = res2.data;
  379. this.renderWorldVirusMap();
  380. this.renderChinaVirusMap();
  381. }
  382. },
  383. created() {
  384. this.initData();
  385. },
  386. mounted() {
  387. setTimeout(() => {
  388. this.renderWorldVirusMap();
  389. this.renderChinaVirusMap();
  390. // this.renderPhyloTree();
  391. }, 500);
  392. }
  393. };
  394. </script>
  395. <style scoped>
  396. .phylo-tree, .world-map {
  397. background: #fff;
  398. padding: 16px;
  399. border-radius: 8px;
  400. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  401. }
  402. </style>