atmsampleStatistics.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. <template>
  2. <div class="boxplot-container">
  3. <div class="chart-container">
  4. <div class="header">
  5. <div class="chart-title">大气重金属浓度统计箱线图</div>
  6. <p>展示各重金属浓度的分布特征(最小值、四分位数、中位数、最大值)</p>
  7. <p class="sample-subtitle">样本来源:{{ totalPoints }}个数据</p>
  8. </div>
  9. <div v-if="isLoading" class="loading-state">
  10. <span class="spinner"></span> 数据加载中...
  11. </div>
  12. <div v-else-if="error" class="error-state">
  13. ❌ 加载失败:{{ error.message }}
  14. </div>
  15. <div v-else>
  16. <div class="chart-wrapper">
  17. <v-chart :option="chartOption" autoresize />
  18. </div>
  19. </div>
  20. </div>
  21. </div>
  22. </template>
  23. <script>
  24. import * as echarts from 'echarts'
  25. import VChart from 'vue-echarts'
  26. import axios from 'axios'
  27. import { ref, onMounted ,computed} from 'vue'
  28. export default {
  29. components: { VChart },
  30. setup() {
  31. // -------- 核心配置 --------
  32. const apiUrl = ref('http://localhost:8000/api/vector/export/all?table_name=Atmo_sample_data')
  33. const heavyMetals = [
  34. { key: 'Cr_particulate', name: '铬 (Cr)', color: '#FF9800' },
  35. { key: 'As_particulate', name: '砷 (As)', color: '#4CAF50' },
  36. { key: 'Cd_particulate', name: '镉 (Cd)', color: '#9C27B0' },
  37. { key: 'Hg_particulate', name: '汞 (Hg)', color: '#2196F3' },
  38. { key: 'Pb_particulate', name: '铅 (Pb)', color: '#F44336' },
  39. // { key: 'particle_weight', name:'大气污染物重量' }
  40. ]
  41. // -------- 状态 --------
  42. const sampleData = ref([]) // 存储 properties 数据
  43. const chartOption = ref({}) // ECharts 配置
  44. const isLoading = ref(true) // 加载状态
  45. const error = ref(null) // 错误信息
  46. const statsByIndex = ref([]) // 缓存统计结果(与 x 轴对齐)
  47. const totalPoints = computed(() => sampleData.value.length)
  48. // -------- 工具函数 --------
  49. /** 日志工具(带颜色区分) */
  50. const log = (message, metalName = '') => {
  51. console.log(
  52. `%c[${metalName || '全局'}] %c${message}`,
  53. 'color:#4CAF50;font-weight:bold',
  54. 'color:#333'
  55. )
  56. }
  57. /** 计算百分位数(线性插值法) */
  58. const calculatePercentile = (sortedArray, percentile) => {
  59. const n = sortedArray.length
  60. if (n === 0) return null
  61. if (percentile <= 0) return sortedArray[0]
  62. if (percentile >= 100) return sortedArray[n - 1]
  63. const index = (n - 1) * (percentile / 100)
  64. const lowerIndex = Math.floor(index)
  65. const upperIndex = lowerIndex + 1
  66. const fraction = index - lowerIndex
  67. if (upperIndex >= n) return sortedArray[lowerIndex]
  68. return sortedArray[lowerIndex] + fraction * (sortedArray[upperIndex] - sortedArray[lowerIndex])
  69. }
  70. // -------- 数据统计 --------
  71. /** 计算每个重金属的箱线图统计量(min/q1/median/q3/max) */
  72. const calculateBoxplotStats = () => {
  73. const stats = []
  74. heavyMetals.forEach((metal) => {
  75. //log(`开始处理 ${metal.name}`, metal.name)
  76. // 1. 提取原始值
  77. const rawValues = sampleData.value.map(item => item[metal.key])
  78. //log(`原始值:[${rawValues.slice(0, 5)}${rawValues.length > 5 ? ', ...' : ''}]`, metal.name)
  79. // 2. 过滤无效值(NaN、非数字)
  80. const values = rawValues
  81. .map((val, idx) => {
  82. const num = Number(val)
  83. if (isNaN(num)) {
  84. log(`⚠️ 第${idx+1}条数据无效: ${val}`, metal.name)
  85. return null
  86. }
  87. return num
  88. })
  89. .filter(v => v !== null)
  90. //log(`有效数据量:${values.length} 条`, metal.name)
  91. // 3. 无有效数据时,记录空统计
  92. if (values.length === 0) {
  93. stats.push({ ...metal, min: null, q1: null, median: null, q3: null, max: null })
  94. return
  95. }
  96. // 4. 排序并计算统计量
  97. const sorted = [...values].sort((a, b) => a - b)
  98. const min = sorted[0]
  99. const max = sorted[sorted.length - 1]
  100. const q1 = calculatePercentile(sorted, 25)
  101. const median = calculatePercentile(sorted, 50)
  102. const q3 = calculatePercentile(sorted, 75)
  103. //log(`统计结果:min=${min}, q1=${q1}, median=${median}, q3=${q3}, max=${max}`, metal.name)
  104. stats.push({ ...metal, min, q1, median, q3, max })
  105. })
  106. return stats
  107. }
  108. /** 构建 ECharts 箱线图数据 */
  109. const buildBoxplotData = (stats) => {
  110. const xAxisData = heavyMetals.map(m => m.name)
  111. // 与 x 轴顺序对齐(确保 tooltip 能正确匹配)
  112. statsByIndex.value = heavyMetals.map(m =>
  113. stats.find(s => s.key === m.key) || { ...m, min: null, q1: null, median: null, q3: null, max: null }
  114. )
  115. // 生成箱线图数据:[min, q1, median, q3, max]
  116. const data = statsByIndex.value.map(s =>
  117. s.min === null ? [null, null, null, null, null] : [s.min, s.q1, s.median, s.q3, s.max]
  118. )
  119. return { xAxisData, data }
  120. }
  121. // -------- 图表初始化 --------
  122. const initChart = () => {
  123. //log('开始初始化图表')
  124. const stats = calculateBoxplotStats()
  125. const { xAxisData, data } = buildBoxplotData(stats)
  126. // ECharts 配置(重点检查 series 数据格式)
  127. chartOption.value = {
  128. tooltip: {
  129. trigger: 'item',
  130. formatter: (params) => {
  131. const s = statsByIndex.value[params.dataIndex]
  132. if (!s || s.min === null) {
  133. return `<div style="font-weight:bold;color:#f56c6c">${xAxisData[params.dataIndex]}</div><div>无有效数据</div>`
  134. }
  135. return `<div style="font-weight:bold">${xAxisData[params.dataIndex]}</div>
  136. <div style="margin-top:8px">
  137. <div>最小值:<span style="color:#5a5;">${s.min.toFixed(4)}</span></div>
  138. <div>下四分位:<span style="color:#d87a80;">${s.q1.toFixed(4)}</span></div>
  139. <div>中位数:<span style="color:#f56c6c;font-weight:bold;">${s.median.toFixed(4)}</span></div>
  140. <div>上四分位:<span style="color:#d87a80;">${s.q3.toFixed(4)}</span></div>
  141. <div>最大值:<span style="color:#5a5;">${s.max.toFixed(4)}</span></div>
  142. </div>`
  143. }
  144. },
  145. xAxis: {
  146. type: 'category',
  147. data: xAxisData,
  148. name: '重金属类型',
  149. nameLocation: 'middle',
  150. nameGap: 45,
  151. axisLabel: { color: '#555', rotate: 30, fontWeight: 'bold',fontSize:11 }
  152. },
  153. yAxis: {
  154. type: 'value',
  155. name: 'mg/kg',
  156. nameLocation: 'end',
  157. nameGap: 5,
  158. axisLabel: { color: '#555', fontWeight: 'bold' ,fontSize:11},
  159. splitLine: { lineStyle: { color: '#f0f0f0' } }
  160. },
  161. series: [{
  162. name: '重金属浓度分布',
  163. type: 'boxplot',
  164. data, // 必须是 [[min,q1,median,q3,max], ...] 格式
  165. itemStyle: {
  166. color: (p) => (heavyMetals[p.dataIndex]?.color || '#1890ff'),
  167. borderWidth: 2
  168. },
  169. emphasis: {
  170. itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.2)', borderWidth: 3 }
  171. }
  172. }],
  173. grid: { top: '8%', right: '5%', left: '5%', bottom: '20%' }
  174. }
  175. isLoading.value = false
  176. //log('图表初始化完成')
  177. }
  178. // -------- 接口请求 --------
  179. onMounted(async () => {
  180. try {
  181. //log('发起API请求...')
  182. const response = await axios.get(apiUrl.value)
  183. //console.log('接口原始响应:', response.data) // 调试必看!
  184. let data = response.data
  185. // ✅ 兼容接口返回字符串的情况(比如后端没设置 application/json)
  186. if (typeof data === 'string') {
  187. try {
  188. // 兼容 NaN(非标准 JSON 值)→ 替换为 null
  189. data = JSON.parse(data.replace(/\bNaN\b/g, 'null'))
  190. } catch (parseErr) {
  191. throw new Error('接口返回的是字符串,但 JSON 解析失败')
  192. }
  193. }
  194. // 1. 分情况提取 features(严格校验结构)
  195. let features = []
  196. if (data?.type === 'FeatureCollection') {
  197. // 情况1:标准 GeoJSON FeatureCollection
  198. if (Array.isArray(data.features)) {
  199. features = data.features
  200. } else {
  201. throw new Error('FeatureCollection 中 features 不是数组')
  202. }
  203. } else if (Array.isArray(data)) {
  204. // 情况2:直接返回 features 数组
  205. features = data
  206. } else {
  207. // 情况3:其他非法结构
  208. throw new Error(`接口结构异常,响应:${JSON.stringify(data)}`)
  209. }
  210. // 2. 提取 properties 数据(确保非空)
  211. sampleData.value = features.map(f => f.properties)
  212. if (sampleData.value.length === 0) {
  213. throw new Error('接口返回数据为空(properties 为空)')
  214. }
  215. //log(`成功提取 ${sampleData.value.length} 条数据`, '接口')
  216. // 3. 初始化图表
  217. initChart()
  218. } catch (err) {
  219. error.value = err
  220. isLoading.value = false
  221. console.error('接口请求失败:', err)
  222. }
  223. })
  224. return {
  225. chartOption,
  226. isLoading,
  227. error,
  228. totalPoints
  229. }
  230. }
  231. }
  232. </script>
  233. <style scoped>
  234. .boxplot-container {
  235. width: 100%;
  236. height: 300px;
  237. max-width: 1000px;
  238. margin: 0 auto;
  239. }
  240. .header {
  241. text-align: left;
  242. margin-bottom: 20px;
  243. }
  244. .chart-title {
  245. font-size: 14px;
  246. color: #2980b9;
  247. font-weight: 600;
  248. }
  249. .header p {
  250. font-size: 0.6rem;
  251. color: #666;
  252. margin: 0;
  253. }
  254. .loading-state {
  255. text-align: center;
  256. padding: 40px 0;
  257. color: #666;
  258. }
  259. .loading-state .spinner {
  260. display: inline-block;
  261. width: 24px;
  262. height: 24px;
  263. margin-right: 8px;
  264. border: 3px solid #ccc;
  265. border-top-color: #1890ff;
  266. border-radius: 50%;
  267. animation: spin 1s linear infinite;
  268. }
  269. @keyframes spin {
  270. to { transform: rotate(360deg); }
  271. }
  272. .error-state {
  273. text-align: center;
  274. padding: 40px 0;
  275. color: #f56c6c;
  276. }
  277. .chart-wrapper {
  278. width: 100%;
  279. height: 220px; /* 确保高度有效 */
  280. }
  281. .chart-container {
  282. background: white;
  283. border-radius: 12px;
  284. box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
  285. padding: 20px;
  286. margin-bottom: 25px;
  287. }
  288. .sample-subtitle {
  289. font-size: 0.85rem;
  290. color: #888;
  291. margin-top: 4px;
  292. }
  293. </style>