123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- <template>
- <div class="boxplot-container">
- <div class="chart-container">
- <div class="header">
- <div class="chart-title">大气重金属浓度统计箱线图</div>
- <p>展示各重金属浓度的分布特征(最小值、四分位数、中位数、最大值)</p>
- <p class="sample-subtitle">样本来源:{{ totalPoints }}个数据</p>
- </div>
- <div v-if="isLoading" class="loading-state">
- <span class="spinner"></span> 数据加载中...
- </div>
- <div v-else-if="error" class="error-state">
- ❌ 加载失败:{{ error.message }}
- </div>
- <div v-else>
- <div class="chart-wrapper">
- <v-chart :option="chartOption" autoresize />
- </div>
- </div>
- </div>
- </div>
- </template>
- <script>
- import * as echarts from 'echarts'
- import VChart from 'vue-echarts'
- import axios from 'axios'
- import { ref, onMounted ,computed} from 'vue'
- export default {
- components: { VChart },
- setup() {
- // -------- 核心配置 --------
- const apiUrl = ref('http://localhost:8000/api/vector/export/all?table_name=Atmo_sample_data')
- const heavyMetals = [
- { key: 'Cr_particulate', name: '铬 (Cr)', color: '#FF9800' },
- { key: 'As_particulate', name: '砷 (As)', color: '#4CAF50' },
- { key: 'Cd_particulate', name: '镉 (Cd)', color: '#9C27B0' },
- { key: 'Hg_particulate', name: '汞 (Hg)', color: '#2196F3' },
- { key: 'Pb_particulate', name: '铅 (Pb)', color: '#F44336' },
- // { key: 'particle_weight', name:'大气污染物重量' }
- ]
- // -------- 状态 --------
- const sampleData = ref([]) // 存储 properties 数据
- const chartOption = ref({}) // ECharts 配置
- const isLoading = ref(true) // 加载状态
- const error = ref(null) // 错误信息
- const statsByIndex = ref([]) // 缓存统计结果(与 x 轴对齐)
- const totalPoints = computed(() => sampleData.value.length)
- // -------- 工具函数 --------
- /** 日志工具(带颜色区分) */
- const log = (message, metalName = '') => {
- console.log(
- `%c[${metalName || '全局'}] %c${message}`,
- 'color:#4CAF50;font-weight:bold',
- 'color:#333'
- )
- }
- /** 计算百分位数(线性插值法) */
- const calculatePercentile = (sortedArray, percentile) => {
- const n = sortedArray.length
- if (n === 0) return null
- if (percentile <= 0) return sortedArray[0]
- if (percentile >= 100) return sortedArray[n - 1]
- const index = (n - 1) * (percentile / 100)
- const lowerIndex = Math.floor(index)
- const upperIndex = lowerIndex + 1
- const fraction = index - lowerIndex
- if (upperIndex >= n) return sortedArray[lowerIndex]
- return sortedArray[lowerIndex] + fraction * (sortedArray[upperIndex] - sortedArray[lowerIndex])
- }
- // -------- 数据统计 --------
- /** 计算每个重金属的箱线图统计量(min/q1/median/q3/max) */
- const calculateBoxplotStats = () => {
- const stats = []
- heavyMetals.forEach((metal) => {
- //log(`开始处理 ${metal.name}`, metal.name)
- // 1. 提取原始值
- const rawValues = sampleData.value.map(item => item[metal.key])
- //log(`原始值:[${rawValues.slice(0, 5)}${rawValues.length > 5 ? ', ...' : ''}]`, metal.name)
- // 2. 过滤无效值(NaN、非数字)
- const values = rawValues
- .map((val, idx) => {
- const num = Number(val)
- if (isNaN(num)) {
- log(`⚠️ 第${idx+1}条数据无效: ${val}`, metal.name)
- return null
- }
- return num
- })
- .filter(v => v !== null)
- //log(`有效数据量:${values.length} 条`, metal.name)
- // 3. 无有效数据时,记录空统计
- if (values.length === 0) {
- stats.push({ ...metal, min: null, q1: null, median: null, q3: null, max: null })
- return
- }
- // 4. 排序并计算统计量
- const sorted = [...values].sort((a, b) => a - b)
- const min = sorted[0]
- const max = sorted[sorted.length - 1]
- const q1 = calculatePercentile(sorted, 25)
- const median = calculatePercentile(sorted, 50)
- const q3 = calculatePercentile(sorted, 75)
- //log(`统计结果:min=${min}, q1=${q1}, median=${median}, q3=${q3}, max=${max}`, metal.name)
- stats.push({ ...metal, min, q1, median, q3, max })
- })
- return stats
- }
- /** 构建 ECharts 箱线图数据 */
- const buildBoxplotData = (stats) => {
- const xAxisData = heavyMetals.map(m => m.name)
- // 与 x 轴顺序对齐(确保 tooltip 能正确匹配)
- statsByIndex.value = heavyMetals.map(m =>
- stats.find(s => s.key === m.key) || { ...m, min: null, q1: null, median: null, q3: null, max: null }
- )
- // 生成箱线图数据:[min, q1, median, q3, max]
- const data = statsByIndex.value.map(s =>
- s.min === null ? [null, null, null, null, null] : [s.min, s.q1, s.median, s.q3, s.max]
- )
- return { xAxisData, data }
- }
- // -------- 图表初始化 --------
- const initChart = () => {
- //log('开始初始化图表')
- const stats = calculateBoxplotStats()
- const { xAxisData, data } = buildBoxplotData(stats)
- // ECharts 配置(重点检查 series 数据格式)
- chartOption.value = {
- tooltip: {
- trigger: 'item',
- formatter: (params) => {
- const s = statsByIndex.value[params.dataIndex]
- if (!s || s.min === null) {
- return `<div style="font-weight:bold;color:#f56c6c">${xAxisData[params.dataIndex]}</div><div>无有效数据</div>`
- }
-
- return `<div style="font-weight:bold">${xAxisData[params.dataIndex]}</div>
- <div style="margin-top:8px">
- <div>最小值:<span style="color:#5a5;">${s.min.toFixed(4)}</span></div>
- <div>下四分位:<span style="color:#d87a80;">${s.q1.toFixed(4)}</span></div>
- <div>中位数:<span style="color:#f56c6c;font-weight:bold;">${s.median.toFixed(4)}</span></div>
- <div>上四分位:<span style="color:#d87a80;">${s.q3.toFixed(4)}</span></div>
- <div>最大值:<span style="color:#5a5;">${s.max.toFixed(4)}</span></div>
- </div>`
- }
- },
- xAxis: {
- type: 'category',
- data: xAxisData,
- name: '重金属类型',
- nameLocation: 'middle',
- nameGap: 45,
- axisLabel: { color: '#555', rotate: 30, fontWeight: 'bold',fontSize:11 }
- },
- yAxis: {
- type: 'value',
- name: 'mg/kg',
- nameLocation: 'end',
- nameGap: 5,
- axisLabel: { color: '#555', fontWeight: 'bold' ,fontSize:11},
- splitLine: { lineStyle: { color: '#f0f0f0' } }
- },
- series: [{
- name: '重金属浓度分布',
- type: 'boxplot',
- data, // 必须是 [[min,q1,median,q3,max], ...] 格式
- itemStyle: {
- color: (p) => (heavyMetals[p.dataIndex]?.color || '#1890ff'),
- borderWidth: 2
- },
- emphasis: {
- itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.2)', borderWidth: 3 }
- }
- }],
- grid: { top: '8%', right: '5%', left: '5%', bottom: '20%' }
- }
- isLoading.value = false
- //log('图表初始化完成')
- }
- // -------- 接口请求 --------
- onMounted(async () => {
- try {
- //log('发起API请求...')
- const response = await axios.get(apiUrl.value)
- //console.log('接口原始响应:', response.data) // 调试必看!
- let data = response.data
- // ✅ 兼容接口返回字符串的情况(比如后端没设置 application/json)
- if (typeof data === 'string') {
- try {
- // 兼容 NaN(非标准 JSON 值)→ 替换为 null
- data = JSON.parse(data.replace(/\bNaN\b/g, 'null'))
- } catch (parseErr) {
- throw new Error('接口返回的是字符串,但 JSON 解析失败')
- }
- }
- // 1. 分情况提取 features(严格校验结构)
- let features = []
- if (data?.type === 'FeatureCollection') {
- // 情况1:标准 GeoJSON FeatureCollection
- if (Array.isArray(data.features)) {
- features = data.features
- } else {
- throw new Error('FeatureCollection 中 features 不是数组')
- }
- } else if (Array.isArray(data)) {
- // 情况2:直接返回 features 数组
- features = data
- } else {
- // 情况3:其他非法结构
- throw new Error(`接口结构异常,响应:${JSON.stringify(data)}`)
- }
- // 2. 提取 properties 数据(确保非空)
- sampleData.value = features.map(f => f.properties)
- if (sampleData.value.length === 0) {
- throw new Error('接口返回数据为空(properties 为空)')
- }
- //log(`成功提取 ${sampleData.value.length} 条数据`, '接口')
- // 3. 初始化图表
- initChart()
- } catch (err) {
- error.value = err
- isLoading.value = false
- console.error('接口请求失败:', err)
- }
- })
- return {
- chartOption,
- isLoading,
- error,
- totalPoints
- }
- }
- }
- </script>
- <style scoped>
- .boxplot-container {
- width: 100%;
- height: 300px;
- max-width: 1000px;
- margin: 0 auto;
- }
- .header {
- text-align: left;
- margin-bottom: 20px;
- }
- .chart-title {
- font-size: 14px;
- color: #2980b9;
- font-weight: 600;
- }
- .header p {
- font-size: 0.6rem;
- color: #666;
- margin: 0;
- }
- .loading-state {
- text-align: center;
- padding: 40px 0;
- color: #666;
- }
- .loading-state .spinner {
- display: inline-block;
- width: 24px;
- height: 24px;
- margin-right: 8px;
- border: 3px solid #ccc;
- border-top-color: #1890ff;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- }
- @keyframes spin {
- to { transform: rotate(360deg); }
- }
- .error-state {
- text-align: center;
- padding: 40px 0;
- color: #f56c6c;
- }
- .chart-wrapper {
- width: 100%;
- height: 220px; /* 确保高度有效 */
- }
- .chart-container {
- background: white;
- border-radius: 12px;
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
- padding: 20px;
- margin-bottom: 25px;
- }
- .sample-subtitle {
- font-size: 0.85rem;
- color: #888;
- margin-top: 4px;
- }
- </style>
|