|
|
@@ -0,0 +1,440 @@
|
|
|
+<template>
|
|
|
+ <div class="soil-dashboard p-4 bg-white min-h-screen">
|
|
|
+ <div class="flex justify-between items-center mb-6">
|
|
|
+ <h1 class="text-xl font-bold text-gray-800">有效态 Cd 数据统计分析</h1>
|
|
|
+ <div class="flex items-center">
|
|
|
+ <div class="stat-card inline-block px-3 py-2">
|
|
|
+ <div class="stat-value text-lg">样本数量{{ stats.samples }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 加载状态 -->
|
|
|
+ <div v-if="isLoading" class="loading-overlay">
|
|
|
+ <span class="spinner"></span>
|
|
|
+ <span class="ml-3 text-gray-700">数据加载中...</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 错误提示 -->
|
|
|
+ <div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
|
|
+ <p>数据加载失败: {{ error.message }}</p>
|
|
|
+ <button class="mt-2 px-3 py-1 bg-red-500 text-white rounded" @click="initCharts">重试</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 1️⃣ 总Cd & 有效态Cd -->
|
|
|
+ <section class="mb-4 chart-container">
|
|
|
+ <h3 class="section-title text-base font-semibold">污染指标</h3>
|
|
|
+ <div ref="cdBarChart" style="width: 100%; height: 415px;"></div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <!-- 2️⃣ 养分元素 -->
|
|
|
+ <section class="mb-4 chart-container">
|
|
|
+ <h3 class="section-title text-base font-semibold">主要养分元素</h3>
|
|
|
+ <div ref="nutrientBoxChart" style="width: 100%; height: 400px;"></div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <!-- 3️⃣ 其他理化性质 -->
|
|
|
+ <section class="chart-container">
|
|
|
+ <div class="flex justify-between items-center mb-3">
|
|
|
+ <h3 class="section-title text-base font-semibold">其他理化性质</h3>
|
|
|
+ </div>
|
|
|
+ <div ref="extraBoxChart" style="height: 400px; width: 100%;"></div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, onMounted, watch, nextTick } from 'vue';
|
|
|
+import * as echarts from 'echarts';
|
|
|
+import axios from 'axios';
|
|
|
+
|
|
|
+// 图表实例引用
|
|
|
+const cdBarChart = ref(null);
|
|
|
+const nutrientBoxChart = ref(null);
|
|
|
+const extraBoxChart = ref(null);
|
|
|
+
|
|
|
+// 图表实例变量
|
|
|
+let chartInstanceCd = null;
|
|
|
+let chartInstanceNutrient = null;
|
|
|
+let chartInstanceExtra = null;
|
|
|
+
|
|
|
+
|
|
|
+// 响应式状态
|
|
|
+const isLoading = ref(true);
|
|
|
+const error = ref(null);
|
|
|
+const stats = ref({
|
|
|
+ totalCdAvg: 0,
|
|
|
+ effCdAvg: 0,
|
|
|
+ samples: 0
|
|
|
+});
|
|
|
+
|
|
|
+// 参考灌溉水代码:按图表类型缓存统计数据(与x轴顺序严格对应)
|
|
|
+const pollutionStats = ref([]); // 污染指标统计(总镉/有效态镉)
|
|
|
+const nutrientStats = ref([]); // 养分元素统计
|
|
|
+const extraStats = ref([]); // 其他理化性质统计
|
|
|
+
|
|
|
+// 字段配置(参考灌溉水的重金属配置方式)
|
|
|
+const fieldConfig = {
|
|
|
+ pollution: [
|
|
|
+ { key: 'TCd_IDW', name: '总镉', color: '#5470c6' ,unit:'mg/kg' , convert: false },
|
|
|
+ { key: 'AvaK_IDW', name: '速效钾', color: '#fac858',unit:'mg/kg', convert: false },
|
|
|
+ { key: 'AvaP_IDW', name: '有效磷', color: '#fac858' ,unit:'mg/kg' , convert: false},
|
|
|
+ { key: 'TMn_IDW', name: '全锰', color: '#73c0de' ,unit:'mg/kg' , convert: false},
|
|
|
+ { key: 'TN_IDW', name: '全氮', color: '#fac858' ,unit:'mg/kg' , convert: true, conversionFactor: 1000},
|
|
|
+ { key: 'TP_IDW', name: '全磷', color: '#fac858' ,unit:'mg/kg', convert: true, conversionFactor: 1000},
|
|
|
+ { key: 'TK_IDW', name: '全钾', color: '#fac858' ,unit:'mg/kg', convert: true, conversionFactor: 1000},
|
|
|
+ ],
|
|
|
+ nutrient: [
|
|
|
+ { key: 'AvaK_IDW', name: '速效钾', color: '#fac858',unit:'mg/kg' , convert: false},
|
|
|
+ { key: 'AvaP_IDW', name: '有效磷', color: '#fac858' ,unit:'mg/kg', convert: false },
|
|
|
+ { key: 'TMn_IDW', name: '全锰', color: '#73c0de' ,unit:'mg/kg', convert: false },
|
|
|
+ { key: 'TN_IDW', name: '全氮', color: '#fac858' ,unit:'mg/kg' , convert: true, conversionFactor: 1000},
|
|
|
+ { key: 'TP_IDW', name: '全磷', color: '#fac858' ,unit:'mg/kg' , convert: true, conversionFactor: 1000},
|
|
|
+ { key: 'TK_IDW', name: '全钾', color: '#fac858' ,unit:'mg/kg', convert: true, conversionFactor: 1000},
|
|
|
+ { key: 'TS_IDW', name: '全硫', unit:'mg/kg',convert:true,conversionFactor:1000}
|
|
|
+ ],
|
|
|
+ extra: [
|
|
|
+ { key: 'TFe_IDW', name: '全铁', color: '#73c0de' ,unit:'%', convert: false},
|
|
|
+ { key: 'TCa_IDW', name: '全钙', color: '#73c0de' ,unit:'%', convert: false},
|
|
|
+ { key: 'TMg_IDW', name: '全镁', color: '#73c0de' ,unit:'%', convert: false},
|
|
|
+ { key: 'TAl_IDW', name: '全铝', color: '#73c0de' ,unit:'%', convert: false}
|
|
|
+ ]
|
|
|
+};
|
|
|
+
|
|
|
+
|
|
|
+// 数据请求
|
|
|
+const fetchData = async () => {
|
|
|
+ try {
|
|
|
+ // 实际项目中替换为真实API
|
|
|
+ const res = await axios.get("http://localhost:8000/api/vector/export/all?table_name=EffCd_input_data&format=json");
|
|
|
+ return res.data;
|
|
|
+
|
|
|
+ } catch (err) {
|
|
|
+ throw new Error('数据加载失败: ' + err.message);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 核心:分位数计算(参考灌溉水的精准计算逻辑)
|
|
|
+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];
|
|
|
+
|
|
|
+ // 采用与Excel QUARTILE.INC一致的算法
|
|
|
+ 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]);
|
|
|
+};
|
|
|
+
|
|
|
+// 计算单个字段的箱线图统计量(带顺序校正)
|
|
|
+const calculateFieldStats = (data, fieldKey, fieldName) => {
|
|
|
+ // 获取字段配置
|
|
|
+ const fieldConfigItem = [
|
|
|
+ ...fieldConfig.pollution,
|
|
|
+ ...fieldConfig.nutrient,
|
|
|
+ ...fieldConfig.extra
|
|
|
+ ].find(f => f.key === fieldKey);
|
|
|
+ // 提取并清洗数据
|
|
|
+ const rawValues = data.map(item => item[fieldKey]);
|
|
|
+ const values = rawValues
|
|
|
+ .map((val, idx) => {
|
|
|
+ let num = Number(val);
|
|
|
+ if(fieldConfigItem?.convert && fieldConfigItem.conversionFactor){
|
|
|
+ num = num*fieldConfigItem.conversionFactor;
|
|
|
+ }
|
|
|
+ //if (isNaN(num)) log(`无效数据: 第${idx+1}条 → ${val}`, fieldName);
|
|
|
+ return isNaN(num) ? null : num;
|
|
|
+ })
|
|
|
+ .filter(v => v !== null);
|
|
|
+
|
|
|
+ if (values.length === 0) {
|
|
|
+ //log(`无有效数据`, fieldName);
|
|
|
+ return { key: fieldKey, name: fieldName, min: null, q1: null, median: null, q3: null, max: null };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 排序并计算统计量
|
|
|
+ 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);
|
|
|
+
|
|
|
+ const mean = values.reduce((sum,val)=>sum+val,0)/values.length;
|
|
|
+
|
|
|
+ // 强制校正顺序(核心修复)
|
|
|
+ const sortedStats = [min, q1, median, q3, max].sort((a, b) => a - b);
|
|
|
+
|
|
|
+ //log(`统计量: min=${finalStats.min.toFixed(4)}, q1=${finalStats.q1.toFixed(4)},
|
|
|
+ //median=${finalStats.median.toFixed(4)}, q3=${finalStats.q3.toFixed(4)}, max=${finalStats.max.toFixed(4)}`,
|
|
|
+ //fieldName);
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: fieldKey,
|
|
|
+ name: fieldName,
|
|
|
+ min: sortedStats[0],
|
|
|
+ q1: sortedStats[1],
|
|
|
+ median: sortedStats[2],
|
|
|
+ q3: sortedStats[3],
|
|
|
+ max: sortedStats[4],
|
|
|
+ mean:mean
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+// 批量计算所有字段的统计量(按图表类型缓存)
|
|
|
+const calculateAllStats = (data) => {
|
|
|
+ // 1. 污染指标统计(与x轴顺序一致)
|
|
|
+ pollutionStats.value = fieldConfig.pollution.map(field =>
|
|
|
+ calculateFieldStats(data, field.key, field.name)
|
|
|
+ );
|
|
|
+
|
|
|
+ // 2. 养分元素统计(与x轴顺序一致)
|
|
|
+ nutrientStats.value = fieldConfig.nutrient.map(field =>
|
|
|
+ calculateFieldStats(data, field.key, field.name)
|
|
|
+ );
|
|
|
+
|
|
|
+ // 3. 其他理化性质统计(与x轴顺序一致)
|
|
|
+ extraStats.value = fieldConfig.extra.map(field =>
|
|
|
+ calculateFieldStats(data, field.key, field.name)
|
|
|
+ );
|
|
|
+
|
|
|
+ // 更新平均值统计
|
|
|
+ const totalCdStats = pollutionStats.value.find(s => s.key === 'TCd_IDW');
|
|
|
+ const effCdStats = pollutionStats.value.find(s => s.key === 'Cdsolution');
|
|
|
+ stats.value = {
|
|
|
+ totalCdAvg: totalCdStats ? (totalCdStats.min + totalCdStats.max) / 2 : 0, // 示例:用范围中点模拟平均值
|
|
|
+ effCdAvg: effCdStats ? (effCdStats.min + effCdStats.max) / 2 : 0,
|
|
|
+ samples: data.length
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+// 构建箱线图数据(将统计量转换为ECharts所需格式)
|
|
|
+const buildBoxplotData = (statsArray) => {
|
|
|
+ return statsArray.map(stat => {
|
|
|
+ if (!stat.min) return [null, null, null, null, null];
|
|
|
+ return [stat.min, stat.q1, stat.median, stat.q3, stat.max];
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化污染指标图表(柱状图)
|
|
|
+const initPollutionChart = () => {
|
|
|
+ if (chartInstanceCd) chartInstanceCd.dispose();
|
|
|
+ chartInstanceCd = echarts.init(cdBarChart.value);
|
|
|
+
|
|
|
+ // 提取x轴标签和数据
|
|
|
+ const xAxisData = fieldConfig.pollution.map(f => f.name);
|
|
|
+ const barData = pollutionStats.value.map(stat =>stat.mean);
|
|
|
+
|
|
|
+ chartInstanceCd.setOption({
|
|
|
+ title: { text: '主要指标含量对比', left: 'center', textStyle: { fontSize: 14 } },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ formatter: (params) => `${params[0].name}<br/>平均值: ${params[0].value.toFixed(4)} mg/kg`
|
|
|
+ },
|
|
|
+ grid: { top: 40, right: 15, bottom: 30, left: 40 },
|
|
|
+ xAxis: { type: "category", data: xAxisData, axisLabel: { fontSize: 12 ,rotate:30 } },
|
|
|
+ yAxis: { type: "value", name: '含量 (mg/kg)', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 11 ,rotate:30} },
|
|
|
+ series: [{
|
|
|
+ name: '平均值', type: "bar",
|
|
|
+ itemStyle: {color: '#5470c6' },
|
|
|
+ data: barData
|
|
|
+ }]
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化养分元素图表(箱线图)
|
|
|
+const initNutrientChart = () => {
|
|
|
+ if (chartInstanceNutrient) chartInstanceNutrient.dispose();
|
|
|
+ chartInstanceNutrient = echarts.init(nutrientBoxChart.value);
|
|
|
+
|
|
|
+ const xAxisData = fieldConfig.nutrient.map(f => f.name);
|
|
|
+ const boxData = buildBoxplotData(nutrientStats.value);
|
|
|
+
|
|
|
+ chartInstanceNutrient.setOption({
|
|
|
+ title: { text: "主要养分元素分布", left: 'center', textStyle: { fontSize: 14 } },
|
|
|
+ tooltip: {
|
|
|
+ trigger: "item",
|
|
|
+ formatter: (params) => formatTooltip(nutrientStats.value[params.dataIndex])
|
|
|
+ },
|
|
|
+ grid: { top: 40, right: 15, bottom: 40, left: 40 },
|
|
|
+ xAxis: { type: "category", data: xAxisData, axisLabel: { fontSize: 11, rotate: 30 } },
|
|
|
+ yAxis: { type: "value", name: '含量(mg/kg)', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 11 , rotate: 30 } },
|
|
|
+ series: [{
|
|
|
+ name: '养分元素', type: "boxplot",
|
|
|
+ itemStyle: { color: '#fac858', borderColor: '#ee6666' },
|
|
|
+ data: boxData
|
|
|
+ }]
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化其他理化性质图表(箱线图)
|
|
|
+const initExtraChart = () => {
|
|
|
+ const xAxisData = fieldConfig.extra.map(f => f.name);
|
|
|
+ const boxData = buildBoxplotData(extraStats.value);
|
|
|
+
|
|
|
+ nextTick(() => {
|
|
|
+ if (chartInstanceExtra) chartInstanceExtra.dispose();
|
|
|
+ chartInstanceExtra = echarts.init(extraBoxChart.value);
|
|
|
+ chartInstanceExtra.setOption({
|
|
|
+ title: { text: "其他理化性质分布", left: 'center', textStyle: { fontSize: 14 } },
|
|
|
+ tooltip: {
|
|
|
+ trigger: "item",
|
|
|
+ formatter: (params) => formatTooltip(extraStats.value[params.dataIndex])
|
|
|
+ },
|
|
|
+ grid: { top: 40, right: 15, bottom: 40, left: 40 },
|
|
|
+ xAxis: { type: "category", data: xAxisData, axisLabel: { fontSize: 11, rotate: 30 } },
|
|
|
+ yAxis: { type: "value", name: '%', nameTextStyle: { fontSize: 12 }, axisLabel: { fontSize: 11 } },
|
|
|
+ series: [{
|
|
|
+ name: '理化性质', type: "boxplot",
|
|
|
+ itemStyle: { color: '#73c0de', borderColor: '#5470c6' },
|
|
|
+ data: boxData
|
|
|
+ }]
|
|
|
+ });
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+
|
|
|
+// 格式化Tooltip(复用缓存的统计数据)
|
|
|
+const formatTooltip = (stat) => {
|
|
|
+ if (!stat || !stat.min) {
|
|
|
+ return `<div style="font-weight:bold;color:#f56c6c">${stat?.name || '未知'}</div><div>无有效数据</div>`;
|
|
|
+ }
|
|
|
+ return `<div style="font-weight:bold">${stat.name}</div>
|
|
|
+ <div style="margin-top:8px">
|
|
|
+ <div>最小值:<span style="color:#5a5;">${stat.min.toFixed(4)}</span></div>
|
|
|
+ <div>下四分位:<span style="color:#d87a80;">${stat.q1.toFixed(4)}</span></div>
|
|
|
+ <div>中位数:<span style="color:#f56c6c;font-weight:bold;">${stat.median.toFixed(4)}</span></div>
|
|
|
+ <div>上四分位:<span style="color:#d87a80;">${stat.q3.toFixed(4)}</span></div>
|
|
|
+ <div>最大值:<span style="color:#5a5;">${stat.max.toFixed(4)}</span></div>
|
|
|
+ </div>`;
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化图表主流程
|
|
|
+const initCharts = async () => {
|
|
|
+ try {
|
|
|
+ isLoading.value = true;
|
|
|
+ error.value = null;
|
|
|
+
|
|
|
+ const data = await fetchData();
|
|
|
+ calculateAllStats(data); // 计算并缓存所有统计数据
|
|
|
+
|
|
|
+ // 初始化图表
|
|
|
+ initPollutionChart();
|
|
|
+ initNutrientChart();
|
|
|
+ initExtraChart();
|
|
|
+
|
|
|
+ isLoading.value = false;
|
|
|
+ } catch (err) {
|
|
|
+ isLoading.value = false;
|
|
|
+ error.value = err;
|
|
|
+ console.error('初始化失败:', err);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 组件挂载
|
|
|
+onMounted(() => {
|
|
|
+ initCharts();
|
|
|
+
|
|
|
+ // 窗口 resize 处理
|
|
|
+ const handleResize = () => {
|
|
|
+ [chartInstanceCd, chartInstanceNutrient, chartInstanceExtra, chartInstancePopup]
|
|
|
+ .forEach(inst => inst && inst.resize());
|
|
|
+ };
|
|
|
+ window.addEventListener('resize', handleResize);
|
|
|
+
|
|
|
+ // 组件卸载清理
|
|
|
+ return () => {
|
|
|
+ window.removeEventListener('resize', handleResize);
|
|
|
+ [chartInstanceCd, chartInstanceNutrient, chartInstanceExtra, chartInstancePopup]
|
|
|
+ .forEach(inst => inst && inst.dispose());
|
|
|
+ };
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style>
|
|
|
+.soil-dashboard {
|
|
|
+ font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
|
+ max-width: 1200px;
|
|
|
+ margin: 0 auto;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+.chart-container {
|
|
|
+ background: white;
|
|
|
+ border-radius: 6px;
|
|
|
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
|
+ padding: 12px;
|
|
|
+ margin-bottom: 16px;
|
|
|
+}
|
|
|
+.section-title {
|
|
|
+ color: #2c3e50;
|
|
|
+ border-left: 3px solid #3498db;
|
|
|
+ padding-left: 10px;
|
|
|
+ margin-bottom: 12px;
|
|
|
+}
|
|
|
+.toggle-btn {
|
|
|
+ background: #f8f9fa;
|
|
|
+ border: 1px solid #e9ecef;
|
|
|
+ padding: 6px 12px;
|
|
|
+ border-radius: 3px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ transition: all 0.3s;
|
|
|
+}
|
|
|
+.toggle-btn:hover {
|
|
|
+ background: #e9ecef;
|
|
|
+}
|
|
|
+.loading-overlay {
|
|
|
+ position: absolute;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ background: rgba(255, 255, 255, 0.8);
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ z-index: 10;
|
|
|
+}
|
|
|
+.spinner {
|
|
|
+ width: 30px;
|
|
|
+ height: 30px;
|
|
|
+ border: 3px solid rgba(0, 0, 0, 0.1);
|
|
|
+ border-radius: 50%;
|
|
|
+ border-left-color: #3498db;
|
|
|
+ animation: spin 1s linear infinite;
|
|
|
+}
|
|
|
+@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
+.stat-card {
|
|
|
+ background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
|
|
+}
|
|
|
+.stat-value {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: bold;
|
|
|
+ color: #2c3e50;
|
|
|
+}
|
|
|
+.stat-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #7f8c8d;
|
|
|
+}
|
|
|
+.legend-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-right: 12px;
|
|
|
+ font-size: 13px;
|
|
|
+}
|
|
|
+.legend-color {
|
|
|
+ width: 10px;
|
|
|
+ height: 10px;
|
|
|
+ border-radius: 50%;
|
|
|
+ margin-right: 5px;
|
|
|
+}
|
|
|
+</style>
|