heavyMetalEnterprisechart.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. <template>
  2. <div class="heavy-metal-chart">
  3. <!-- 错误提示(带原始响应预览) -->
  4. <div v-if="error" class="status error">
  5. <i class="fa fa-exclamation-circle"></i> {{ error }}
  6. <div class="raw-response" v-if="rawResponse">
  7. <button @click="showRaw = !showRaw" class="raw-btn">
  8. {{ showRaw ? '收起原始响应' : '查看原始响应(前1000字符)' }}
  9. </button>
  10. <pre v-if="showRaw" class="raw-pre">{{ truncatedRawResponse }}</pre>
  11. </div>
  12. </div>
  13. <!-- 加载状态 -->
  14. <div v-if="loading" class="loading-state">
  15. <div class="spinner"></div>
  16. <p>数据加载中...</p>
  17. </div>
  18. <!-- 图表容器(使用v-show确保DOM始终存在) -->
  19. <div
  20. v-show="!loading && !error"
  21. ref="chartRef"
  22. class="chart-box"
  23. :style="{
  24. height: '500px',
  25. border: '2px solid #1890ff',
  26. position: 'relative'
  27. }"
  28. >
  29. <!-- 容器状态可视化提示(调试用) -->
  30. <div class="container-status" v-if="debugMode">
  31. 容器状态: {{ containerStatus }}
  32. <br>
  33. 高度: {{ containerHeight }}px
  34. </div>
  35. </div>
  36. </div>
  37. </template>
  38. <script setup>
  39. import { ref, onMounted, computed, onUnmounted, nextTick } from 'vue';
  40. import * as echarts from 'echarts';
  41. import axios from 'axios';
  42. // ========== 核心配置 ==========
  43. const API_URL = 'http://localhost:8000/api/vector/export/all?table_name=atmo_company';
  44. const SG_REGIONS = [
  45. '浈江区', '武江区', '曲江区', '乐昌市',
  46. '南雄市', '始兴县', '仁化县', '翁源县',
  47. '新丰县', '乳源县'
  48. ];
  49. const EXCLUDE_FIELDS = [
  50. 'id', 'company_name', 'company_type', 'longitude', 'latitude'
  51. ];
  52. const debugMode = true; // 调试模式:显示容器状态
  53. // ========== 响应式数据 ==========
  54. const chartRef = ref(null);
  55. const error = ref('');
  56. const loading = ref(true);
  57. const rawResponse = ref('');
  58. const showRaw = ref(false);
  59. const containerStatus = ref('未初始化');
  60. const containerHeight = ref(0);
  61. let myChart = null;
  62. // 截断原始响应
  63. const truncatedRawResponse = computed(() => {
  64. return rawResponse.value.length > 1000
  65. ? rawResponse.value.slice(0, 1000) + '...'
  66. : rawResponse.value;
  67. });
  68. // ========== 容器状态检查(实时更新) ==========
  69. const checkContainer = () => {
  70. if (!chartRef.value) {
  71. containerStatus.value = '未找到容器元素';
  72. containerHeight.value = 0;
  73. return false;
  74. }
  75. containerStatus.value = '已找到容器元素';
  76. containerHeight.value = chartRef.value.offsetHeight;
  77. return true;
  78. };
  79. // ========== 数据处理逻辑 ==========
  80. const processData = (features) => {
  81. console.log('🔍 开始处理数据,features数量:', features.length);
  82. // 提取有效properties
  83. const apiData = features
  84. .map(feature => feature.properties || {})
  85. .filter(props => Object.keys(props).length > 0);
  86. console.log('🔍 有效properties数量:', apiData.length);
  87. if (apiData.length === 0) {
  88. throw new Error('无有效数据(properties为空)');
  89. }
  90. // 识别污染物字段
  91. const pollutantFields = Object.keys(apiData[0])
  92. .filter(key =>
  93. !EXCLUDE_FIELDS.includes(key) &&
  94. !isNaN(parseFloat(apiData[0][key]))
  95. );
  96. console.log('🔍 识别的污染物字段:', pollutantFields);
  97. if (pollutantFields.length === 0) {
  98. throw new Error('未识别到有效污染物字段,请检查EXCLUDE_FIELDS');
  99. }
  100. // 按区县统计
  101. const regionStats = {};
  102. const globalStats = {};
  103. let totalSamples = 0;
  104. pollutantFields.forEach(field => {
  105. globalStats[field] = { sum: 0, count: 0 };
  106. });
  107. apiData.forEach(item => {
  108. const county = item.county || '未知区县';
  109. totalSamples++;
  110. if (!regionStats[county]) {
  111. regionStats[county] = {};
  112. pollutantFields.forEach(field => {
  113. regionStats[county][field] = { sum: 0, count: 0 };
  114. });
  115. }
  116. pollutantFields.forEach(field => {
  117. const value = parseFloat(item[field]);
  118. if (!isNaN(value)) {
  119. regionStats[county][field].sum += value;
  120. regionStats[county][field].count++;
  121. globalStats[field].sum += value;
  122. globalStats[field].count++;
  123. }
  124. });
  125. });
  126. console.log('🔍 区县统计结果:', regionStats);
  127. // 构建有效区县
  128. const validRegions = SG_REGIONS.filter(region => regionStats[region])
  129. .concat('全市平均');
  130. console.log('🔍 有效区县列表:', validRegions);
  131. // 构建图表数据
  132. const series = pollutantFields.map((field, index) => ({
  133. name: field,
  134. type: 'bar',
  135. data: validRegions.map(region => {
  136. if (region === '全市平均') {
  137. return globalStats[field].count
  138. ? (globalStats[field].sum / globalStats[field].count)
  139. : 0;
  140. }
  141. return regionStats[region][field].count
  142. ? (regionStats[region][field].sum / regionStats[region][field].count)
  143. : 0;
  144. }),
  145. itemStyle: {
  146. // 当x轴类目是“全市平均”时,柱子显示红色
  147. color: (params) => {
  148. // params.dataIndex 对应 regions 数组的索引,最后一个是“全市平均”
  149. const isTotal = validRegions[params.dataIndex] === '全市平均';
  150. return isTotal ? '#ff0000' : '#1890ff'; // 红色可用 #ff0000 或其他红色值
  151. }
  152. },
  153. label: { show: true, position: 'top', fontSize: 15 }
  154. }));
  155. return { regions: validRegions, series, totalSamples };
  156. };
  157. // ========== ECharts初始化 ==========
  158. const initChart = (data) => {
  159. // 检查容器状态
  160. if (!checkContainer()) {
  161. error.value = '图表容器未准备好,请刷新页面重试';
  162. return;
  163. }
  164. // 检查容器高度
  165. if (containerHeight.value < 100) {
  166. error.value = `容器高度异常(${containerHeight.value}px),请检查样式`;
  167. return;
  168. }
  169. // 销毁旧实例
  170. if (myChart && !myChart.isDisposed()) {
  171. myChart.dispose();
  172. }
  173. // 空数据检查
  174. if (data.series.length === 0 || data.regions.length === 0) {
  175. error.value = '无有效数据用于绘制图表';
  176. return;
  177. }
  178. // 初始化图表
  179. try {
  180. myChart = echarts.init(chartRef.value);
  181. myChart.setOption({
  182. title: {
  183. text: '韶关市各区县企业污染物平均值',
  184. left: 'center',
  185. subtext: `基于 ${data.totalSamples} 个有效样本`,
  186. subtextStyle: { fontSize: 15 }
  187. },
  188. tooltip: {
  189. trigger: 'axis',
  190. formatter: (params) => {
  191. let content = `${params[0].name}:<br>`;
  192. params.forEach(p => {
  193. content += `${p.seriesName}: ${p.value} t/a<br>`;
  194. });
  195. return content;
  196. },
  197. axisLabel:{fontSize:15}
  198. },
  199. xAxis: {
  200. type: 'category',
  201. data: data.regions,
  202. axisLabel: { rotate: 30, fontSize: 15 }
  203. },
  204. yAxis: {
  205. type: 'value',
  206. name: '浓度 (t/a)',
  207. nameTextStyle: { fontSize: 15 },
  208. axisLabel:{fontSize:15},
  209. },
  210. series: data.series,
  211. grid: { left: '5%', right: '5%', bottom: '5%', containLabel: true }
  212. });
  213. console.log('✅ 图表初始化成功');
  214. } catch (err) {
  215. error.value = `图表初始化失败:${err.message}`;
  216. console.error('图表初始化错误:', err);
  217. }
  218. };
  219. // ========== 数据请求逻辑 ==========
  220. const fetchData = async () => {
  221. try {
  222. loading.value = true;
  223. error.value = '';
  224. console.log('🚀 开始请求数据:', API_URL);
  225. // 发起请求(延长超时)
  226. const response = await axios.get(API_URL, {
  227. timeout: 20000, // 20秒超时
  228. responseType: 'text'
  229. });
  230. rawResponse.value = response.data;
  231. console.log('✅ 数据请求成功,状态码:', response.status);
  232. // 修复NaN并解析
  233. const fixedJson = response.data.replace(/:\s*NaN/g, ': null');
  234. const geoJSONData = JSON.parse(fixedJson);
  235. // 校验数据结构
  236. if (!geoJSONData.features || !Array.isArray(geoJSONData.features)) {
  237. throw new Error('响应数据缺少features数组');
  238. }
  239. // 处理数据
  240. const chartData = processData(geoJSONData.features);
  241. console.log('✅ 数据处理完成,准备渲染图表');
  242. // 等待DOM更新(双重保险)
  243. await nextTick();
  244. console.log('🔄 DOM更新完成,检查容器:', chartRef.value);
  245. // 强制延迟确保容器准备好(极端情况处理)
  246. setTimeout(() => {
  247. initChart(chartData);
  248. }, 300);
  249. } catch (err) {
  250. error.value = `数据加载失败:${err.message}`;
  251. console.error('❌ 数据请求错误:', err);
  252. } finally {
  253. loading.value = false;
  254. }
  255. };
  256. // ========== 生命周期 ==========
  257. onMounted(() => {
  258. // 初始检查容器
  259. checkContainer();
  260. // 开始加载数据
  261. fetchData();
  262. });
  263. // ========== 响应式布局 ==========
  264. const handleResize = () => {
  265. if (myChart) {
  266. myChart.resize();
  267. console.log('🔄 图表已重绘');
  268. }
  269. };
  270. onMounted(() => window.addEventListener('resize', handleResize));
  271. onUnmounted(() => window.removeEventListener('resize', handleResize));
  272. </script>
  273. <style scoped>
  274. .heavy-metal-chart {
  275. width: 90%;
  276. max-width: 1200px;
  277. margin: 20px auto;
  278. padding: 20px;
  279. background: #fff;
  280. border-radius: 12px;
  281. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  282. position: relative;
  283. }
  284. /* 错误提示 */
  285. .status.error {
  286. color: #dc2626;
  287. background: #fee2e2;
  288. padding: 12px 16px;
  289. border-radius: 6px;
  290. margin-bottom: 16px;
  291. }
  292. .raw-btn {
  293. margin: 8px 0;
  294. padding: 4px 8px;
  295. background: #ff4d4f;
  296. color: white;
  297. border: none;
  298. border-radius: 4px;
  299. cursor: pointer;
  300. font-size: 18px;
  301. }
  302. .raw-pre {
  303. white-space: pre-wrap;
  304. word-break: break-all;
  305. background: #f9fafb;
  306. padding: 8px;
  307. border-radius: 4px;
  308. font-size: 18px;
  309. max-height: 200px;
  310. overflow: auto;
  311. }
  312. /* 加载状态 */
  313. .loading-state {
  314. text-align: center;
  315. padding: 60px 0;
  316. color: #6b7280;
  317. }
  318. .spinner {
  319. width: 40px;
  320. height: 40px;
  321. margin: 0 auto 16px;
  322. border: 4px solid #e5e7eb;
  323. border-top: 4px solid #3b82f6;
  324. border-radius: 50%;
  325. animation: spin 1s linear infinite;
  326. }
  327. @keyframes spin {
  328. 0% { transform: rotate(0deg); }
  329. 100% { transform: rotate(360deg); }
  330. }
  331. /* 图表容器 */
  332. .chart-box {
  333. width: 100%;
  334. min-height: 500px !important; /* 强制最小高度 */
  335. border-radius: 8px;
  336. overflow: hidden;
  337. }
  338. /* 容器状态提示 */
  339. .container-status {
  340. position: absolute;
  341. top: 10px;
  342. left: 10px;
  343. background: rgba(255,255,255,0.8);
  344. padding: 4px 8px;
  345. border-radius: 4px;
  346. font-size: 12px;
  347. color: #1890ff;
  348. z-index: 10;
  349. }
  350. </style>