|
@@ -0,0 +1,259 @@
|
|
|
+<template>
|
|
|
+ <div class="container">
|
|
|
+ <div class="chart-container">
|
|
|
+ <div ref="chartRef" class="chart-wrapper"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
|
|
+import * as echarts from 'echarts'
|
|
|
+
|
|
|
+const indicators = [
|
|
|
+ { key: 'Al3_plus', name: 'Al3_plus', color: '#3498db' },
|
|
|
+ { key: 'CEC', name: 'CEC', color: '#2ecc71' },
|
|
|
+ { key: 'CL', name: 'CL', color: '#e74c3c' },
|
|
|
+ { key: 'Delta_pH', name: 'Delta_pH', color: '#f39c12' },
|
|
|
+ { key: 'H_plus', name: 'H_plus', color: '#9b59b6' },
|
|
|
+ { key: 'N', name: 'N', color: '#1abc9c' },
|
|
|
+ { key: 'OM', name: 'OM', color: '#d35400' }
|
|
|
+];
|
|
|
+
|
|
|
+let chart = ref(null) // 存储 ECharts 实例
|
|
|
+let chartData = ref([]) // 存储图表数据
|
|
|
+const chartRef = ref(null) // 图表容器
|
|
|
+const statsByIndex = ref([]);//存储每个指标的统计量
|
|
|
+const isLoading = ref(true);
|
|
|
+const error = ref(false);
|
|
|
+const errorMessage = ref('');
|
|
|
+
|
|
|
+function initChart() {
|
|
|
+ // 确保图表容器已渲染
|
|
|
+ if (!chartRef.value) return;
|
|
|
+
|
|
|
+
|
|
|
+ //console.log('图表容器宽高:', chartRef.value.clientWidth, chartRef.value.clientHeight);
|
|
|
+
|
|
|
+ // 初始化 ECharts 实例
|
|
|
+ chart.value = echarts.init(chartRef.value);
|
|
|
+
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ axisPointer: { type: 'shadow' },
|
|
|
+ formatter: function(params) {
|
|
|
+ const stat = statsByIndex.value[params.dataIndex];
|
|
|
+ if (!stat || stat.min === null) {
|
|
|
+ return `<div style="font-weight:bold;margin-bottom:8px;color:#2c3e50;">${params.name}</div>
|
|
|
+ <div>无有效数据</div>`;
|
|
|
+ }
|
|
|
+ return `
|
|
|
+ <div style="font-weight:bold;margin-bottom:8px;color:#2c3e50;">${stat.name}</div>
|
|
|
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;">
|
|
|
+ <span style="color:#7f8c8d;">最小值:</span> <span style="text-align:right;font-weight:bold;">${stat.min}</span>
|
|
|
+ <span style="color:#7f8c8d;">下四分位:</span> <span style="text-align:right;font-weight:bold;color:#e74c3c;">${stat.q1}</span>
|
|
|
+ <span style="color:#7f8c8d;">中位数:</span> <span style="text-align:right;font-weight:bold;color:#3498db;">${stat.median}</span>
|
|
|
+ <span style="color:#7f8c8d;">上四分位:</span> <span style="text-align:right;font-weight:bold;color:#e74c3c;">${stat.q3}</span>
|
|
|
+ <span style="color:#7f8c8d;">最大值:</span> <span style="text-align:right;font-weight:bold;">${stat.max}</span>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ title: {
|
|
|
+ text: '酸化加剧指标箱线图展示',
|
|
|
+ left: 'center',
|
|
|
+ textStyle: {
|
|
|
+ fontSize: 18,
|
|
|
+ fontWeight: 'normal'
|
|
|
+ },
|
|
|
+ top: 10
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '0px',
|
|
|
+ right: '0px',
|
|
|
+ bottom: '0px',
|
|
|
+ top: '70px',
|
|
|
+ containLabel: true
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: indicators.map(i => i.name),
|
|
|
+ axisLabel: {
|
|
|
+ rotate: 30,
|
|
|
+ fontSize: 12,
|
|
|
+ margin: 15
|
|
|
+ },
|
|
|
+ axisTick: {
|
|
|
+ alignWithLabel: true
|
|
|
+ },
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '数值',
|
|
|
+ nameTextStyle: {
|
|
|
+ fontSize: 14
|
|
|
+ },
|
|
|
+ nameGap: 25,
|
|
|
+ splitLine: {
|
|
|
+ lineStyle: {
|
|
|
+ type: 'dashed',
|
|
|
+ color: '#ddd'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ series: [{
|
|
|
+ name: '指标分布',
|
|
|
+ type: 'boxplot',
|
|
|
+ itemStyle: {
|
|
|
+ color: function(params) {
|
|
|
+ return indicators[params.dataIndex].color;
|
|
|
+ },
|
|
|
+ borderWidth: 2
|
|
|
+ },
|
|
|
+ emphasis: {
|
|
|
+ itemStyle: {
|
|
|
+ shadowBlur: 10,
|
|
|
+ shadowColor: 'rgba(0, 0, 0, 0.3)'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }]
|
|
|
+ };
|
|
|
+
|
|
|
+ chart.value.setOption(option);
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+function 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]);
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+function calculateBoxplotStats(data, indicators) {
|
|
|
+ const boxplotData = [];
|
|
|
+ const statsArray = [];
|
|
|
+
|
|
|
+ indicators.forEach(indicator => {
|
|
|
+ const values = data
|
|
|
+ .map(item => Number(item[indicator.key]))
|
|
|
+ .filter(val => !isNaN(val))
|
|
|
+ .sort((a, b) => a - b);
|
|
|
+
|
|
|
+ if (values.length === 0) {
|
|
|
+ boxplotData.push([null, null, null, null, null]);
|
|
|
+ statsArray.push({
|
|
|
+ min: null, q1: null, median: null, q3: null, max: null,
|
|
|
+ name: indicator.name,
|
|
|
+ color: indicator.color
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ const min = Math.min(...values);
|
|
|
+ const max = Math.max(...values);
|
|
|
+ const q1 = calculatePercentile(values, 25);
|
|
|
+ const median = calculatePercentile(values, 50);
|
|
|
+ const q3 = calculatePercentile(values, 75);
|
|
|
+ boxplotData.push([min, q1, median, q3, max]);
|
|
|
+
|
|
|
+ statsArray.push({
|
|
|
+ min, q1, median, q3, max,
|
|
|
+ name: indicator.name,
|
|
|
+ color: indicator.color
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return { boxplotData, statsArray };
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+async function loadData() {
|
|
|
+ isLoading.value = true;
|
|
|
+ error.value = false;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await fetch('http://localhost:5000/api/table-data?table_name=dataset_60');
|
|
|
+ const result = await response.json();
|
|
|
+ chartData.value = result.data;
|
|
|
+
|
|
|
+ const { boxplotData, statsArray } = calculateBoxplotStats(chartData.value, indicators);
|
|
|
+ statsByIndex.value = statsArray;
|
|
|
+
|
|
|
+ chart.value.setOption({
|
|
|
+ series: [{
|
|
|
+ data: boxplotData
|
|
|
+ }]
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (err) {
|
|
|
+ error.value = true;
|
|
|
+ errorMessage.value = `加载失败: ${err.message || '网络错误'}`;
|
|
|
+ console.error('数据加载失败:', err);
|
|
|
+ } finally {
|
|
|
+ isLoading.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleResize = () => {
|
|
|
+ if (chart.value) {
|
|
|
+ chart.value.resize();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ nextTick(() => {
|
|
|
+ initChart();
|
|
|
+ loadData();
|
|
|
+ });
|
|
|
+ window.addEventListener('resize', handleResize);
|
|
|
+});
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ if (chart.value) {
|
|
|
+ chart.value.dispose(); // 销毁 ECharts 实例
|
|
|
+ chart.value = null;
|
|
|
+ }
|
|
|
+ window.removeEventListener('resize', handleResize);
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+* {
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+ box-sizing: border-box;
|
|
|
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
|
+}
|
|
|
+
|
|
|
+.container {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ margin: 0 auto;
|
|
|
+ background: white;
|
|
|
+ border-radius: 12px;
|
|
|
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.chart-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ padding: 20px;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+.chart-wrapper {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ min-height: 200px;
|
|
|
+}
|
|
|
+</style>
|