feat: 设备详情页空气质量指数趋势图完善等级背景+柱状堆积图+交互
- 空气质量指数(近24h): 折线图 + 等级背景区域(清洁/未污染/轻污染/中污染/重污染) + 数据点颜色随AQI动态变化 - 空气质量指数(近7日/30日): 柱状堆积图(5等级分段) + Y轴0-24h + 底部图例 + hover显示各等级占比% - 其他指标: SVG折线图 + 上下限超标限制区域/虚线 - 交互: 折线图hover显示日期+数值+彩色圆点; 柱状图hover显示日期+各等级占比百分比 - 参考 web-admin/health-monitor/environment/indoor-data-analysis.html 完整实现
This commit is contained in:
@@ -4,18 +4,18 @@
|
||||
|
||||
---
|
||||
|
||||
## [2026-06-04] feat: 设备详情页变化趋势改造为折线图+新增空气质量指数
|
||||
## [2026-06-04] feat: 设备详情页变化趋势改造+新增空气质量指数
|
||||
|
||||
**涉及文件(1个修改):**
|
||||
- `environment/device-detail.html` — 变化趋势图表从柱状图改为 SVG 折线图,下拉框新增"空气质量指数"选项并默认选中
|
||||
- `environment/device-detail.html` — 变化趋势图表全面重构,参考 web-admin 端 indoor-data-analysis.html 样式
|
||||
|
||||
**变更说明:**
|
||||
- 变化趋势图表从柱状图改为 SVG 折线图,参考 web-admin 端 indoor-data-analysis.html 样式
|
||||
- 折线图支持渐变面积填充、数据点交互提示(Tooltip)
|
||||
- 下拉选择框新增"空气质量指数"虚拟指标,并设为默认选中项
|
||||
- 所有原有指标(温度、湿度、PM2.5 等)均改为折线图方式展示
|
||||
- 支持近24小时(12个数据点)和近7日(7个数据点)两种时间维度
|
||||
- 各指标拥有独立的数值范围配置,确保折线图 Y 轴比例合理
|
||||
- **空气质量指数 - 近24小时**:SVG 折线图 + 渐变面积填充 + AQI 等级背景区域(清洁/未污染/轻污染/中污染/重污染)+ 数据点颜色随 AQI 等级动态变化 + 超标限制虚线
|
||||
- **空气质量指数 - 近7日/近30日**:柱状堆积图,每根柱子按5个AQI等级分段堆积,Y 轴为 0-24 小时分布,底部带图例
|
||||
- **其他指标**:统一改为 SVG 折线图,支持上下限超标限制区域和虚线
|
||||
- **交互提示**:折线图 hover 数据点显示日期+数值+彩色圆点;柱状图 hover 显示日期+各等级占比百分比
|
||||
- 各指标拥有独立的数值范围配置,确保 Y 轴比例合理
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -44,20 +44,26 @@ body { background: #f0f2f5; display: flex; justify-content: center; align-items:
|
||||
.time-tabs { display: flex; gap: 0; background: #f0f2f5; border-radius: 20px; padding: 3px; margin-bottom: 16px; width: fit-content; }
|
||||
.time-tab { padding: 4px 14px; border-radius: 16px; font-size: 12px; color: #999; cursor: pointer; transition: all 0.2s; font-weight: 500; }
|
||||
.time-tab.active { background: #1aad8d; color: #fff; font-weight: 600; }
|
||||
/* 折线图样式 */
|
||||
/* 折线图/柱状图样式 */
|
||||
.line-chart-outer { position: relative; width: 100%; }
|
||||
.line-chart-outer svg { width: 100%; height: auto; }
|
||||
.chart-grid-line { stroke: #f0f0f0; stroke-width: 1; }
|
||||
.chart-axis-label { fill: #bbb; font-size: 9px; }
|
||||
.chart-line { fill: none; stroke: #1aad8d; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.chart-area-fill { fill: url(#trendAreaGrad); }
|
||||
.chart-dot { fill: #1aad8d; stroke: #fff; stroke-width: 1.5; }
|
||||
.chart-dot { stroke: #fff; stroke-width: 1.5; }
|
||||
.chart-x-axis { stroke: #e8e8e8; stroke-width: 1; }
|
||||
.chart-limit-line { stroke: #ff4d4f; stroke-width: 1.5; stroke-dasharray: 6,3; opacity: .7; }
|
||||
.chart-limit-exceed { fill: #ff4d4f; opacity: .08; }
|
||||
.chart-limit-label { fill: #ff4d4f; font-size: 9px; font-weight: 500; }
|
||||
/* 数据点 Tip 弹窗 */
|
||||
.chart-tooltip { display: none; position: fixed; background: #fff; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 8px 12px; z-index: 400; pointer-events: none; font-size: 12px; min-width: 100px; }
|
||||
.chart-tooltip { display: none; position: fixed; background: #fff; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); padding: 8px 12px; z-index: 400; pointer-events: none; font-size: 12px; min-width: 120px; }
|
||||
.chart-tooltip.show { display: block; }
|
||||
.chart-tooltip__label { color: #999; font-size: 11px; }
|
||||
.chart-tooltip__val { font-size: 14px; color: #1a1a1a; font-weight: 700; }
|
||||
.chart-tooltip__date { font-size: 10px; color: #999; margin-bottom: 4px; padding-bottom: 4px; border-bottom: 1px solid #f0f0f0; }
|
||||
.chart-tooltip__row { display: flex; align-items: center; gap: 4px; padding: 2px 0; }
|
||||
.chart-tooltip__dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
|
||||
.chart-tooltip__label { color: #999; font-size: 11px; flex: 1; }
|
||||
.chart-tooltip__val { font-size: 13px; color: #1a1a1a; font-weight: 700; }
|
||||
.bottom-btn-wrap { position: absolute; bottom: 0; left: 0; right: 0; padding: 10px 20px 28px; background: linear-gradient(transparent, #f5f7fa 35%); }
|
||||
.alarm-btn { width: 100%; height: 48px; background: linear-gradient(90deg, #1aad8d, #36cfc9); border: none; border-radius: 24px; color: #fff; font-size: 16px; font-weight: 700; cursor: pointer; box-shadow: 0 4px 16px rgba(26,173,141,0.35); }
|
||||
</style>
|
||||
@@ -235,127 +241,298 @@ const ALL_DATA = {
|
||||
}
|
||||
};
|
||||
|
||||
// 趋势模拟数据生成
|
||||
function genTrend(metricName, baseVal, period) {
|
||||
// 根据指标生成不同的基础值范围
|
||||
/* AQI 等级配置 */
|
||||
var aqiLevels = [
|
||||
{name:'清洁', color:'#13C2C2', range:[0,20]},
|
||||
{name:'未污染', color:'#52C41A', range:[21,50]},
|
||||
{name:'轻污染', color:'#FAAD14', range:[51,100]},
|
||||
{name:'中污染', color:'#FA541C', range:[101,150]},
|
||||
{name:'重污染', color:'#FF4D4F', range:[151,200]}
|
||||
];
|
||||
|
||||
/* 获取 AQI 颜色 */
|
||||
function getAqiColor(val) {
|
||||
if (val >= 151) return '#FF4D4F';
|
||||
if (val >= 101) return '#FA541C';
|
||||
if (val >= 51) return '#FAAD14';
|
||||
if (val >= 21) return '#52C41A';
|
||||
return '#13C2C2';
|
||||
}
|
||||
|
||||
/* 根据数值映射到 SVG Y 坐标 */
|
||||
function valToY(val, yMax) {
|
||||
return chartBottom - (val / yMax) * chartHeight;
|
||||
}
|
||||
|
||||
/* 图表常量(供 renderTrend 内部使用) */
|
||||
var chartLeft = 36, chartRight = 330, chartWidth = chartRight - chartLeft;
|
||||
var chartTop = 15, chartBottom = 140, chartHeight = chartBottom - chartTop;
|
||||
var chartLine = '#1aad8d';
|
||||
|
||||
/* 趋势数据模拟 */
|
||||
function genTrend(metricName, period) {
|
||||
// 各指标的模拟数据
|
||||
var configs = {
|
||||
'空气质量指数': { range: [30, 120], unit: '' },
|
||||
'温度': { range: [18, 35], unit: '℃' },
|
||||
'湿度': { range: [30, 80], unit: '%' },
|
||||
'CO₂': { range: [350, 1100], unit: 'ppm' },
|
||||
'甲醛': { range: [0.02, 0.12], unit: 'mg/m³' },
|
||||
'PM2.5': { range: [20, 150], unit: 'μg/m³' },
|
||||
'PM10': { range: [40, 200], unit: 'μg/m³' },
|
||||
'太阳辐射': { range: [200, 950], unit: 'W/m²' },
|
||||
'照度': { range: [20000, 95000], unit: 'lm/m²' },
|
||||
'风速': { range: [1, 8], unit: 'm/s' },
|
||||
'榆属': { range: [5, 200], unit: '粒/千mm²' },
|
||||
'松属': { range: [5, 150], unit: '粒/千mm²' },
|
||||
'杨属': { range: [8, 280], unit: '粒/千mm²' },
|
||||
'柏科': { range: [3, 110], unit: '粒/千mm²' },
|
||||
'柳属': { range: [5, 170], unit: '粒/千mm²' },
|
||||
'蒿属': { range: [2, 90], unit: '粒/千mm²' },
|
||||
'藜科': { range: [2, 55], unit: '粒/千mm²' },
|
||||
'苋科': { range: [1, 38], unit: '粒/千mm²' },
|
||||
'花粉总数': { range: [50, 1100], unit: '粒/千mm²' },
|
||||
'电导率': { range: [200, 1700], unit: 'μS/cm' },
|
||||
'PH': { range: [6.0, 9.5], unit: '' },
|
||||
'盐度': { range: [100, 950], unit: 'mg/L' },
|
||||
'电阻率': { range: [400, 3200], unit: 'Ω·m' },
|
||||
'TDS': { range: [100, 850], unit: 'mg/L' },
|
||||
'空气质量指数': {
|
||||
isAQI: true, yMax: 200, yTicks: [200,150,100,50,0],
|
||||
limitUpper: null, limitLower: null,
|
||||
levelBands: [
|
||||
{yMin:15,yMax:55,label:'清洁',color:'#13C2C2',opacity:.1},
|
||||
{yMin:55,yMax:105,label:'未污染',color:'#52C41A',opacity:.1},
|
||||
{yMin:105,yMax:135,label:'轻污染',color:'#FAAD14',opacity:.1},
|
||||
{yMin:135,yMax:165,label:'中污染',color:'#FA541C',opacity:.1},
|
||||
{yMin:165,yMax:chartBottom,label:'重污染',color:'#FF4D4F',opacity:.1}
|
||||
],
|
||||
'24h': {points:[55,42,35,48,65,78,82,75,62,58,52,48]},
|
||||
'7d': {barData:[
|
||||
{clean:6,good:8,light:6,moderate:4,heavy:0},
|
||||
{clean:8,good:7,light:5,moderate:4,heavy:0},
|
||||
{clean:4,good:6,light:8,moderate:5,heavy:1},
|
||||
{clean:2,good:5,light:7,moderate:8,heavy:2},
|
||||
{clean:7,good:8,light:5,moderate:4,heavy:0},
|
||||
{clean:9,good:7,light:4,moderate:4,heavy:0},
|
||||
{clean:6,good:9,light:5,moderate:4,heavy:0}
|
||||
]},
|
||||
'30d': {barData:(function(){var r=[];for(var i=0;i<30;i++){r.push({clean:Math.floor(Math.random()*8)+2,good:Math.floor(Math.random()*8)+2,light:Math.floor(Math.random()*6)+2,moderate:Math.floor(Math.random()*6)+2,heavy:Math.floor(Math.random()*3)})}return r})()}
|
||||
}
|
||||
};
|
||||
var cfg = configs[metricName] || { range: [baseVal * 0.6, baseVal * 1.4], unit: '' };
|
||||
|
||||
if (period === '24h') {
|
||||
var labels = ['00:00','02:00','04:00','06:00','08:00','10:00','12:00','14:00','16:00','18:00','20:00','22:00'];
|
||||
var vals = labels.map(function() {
|
||||
var v = cfg.range[0] + Math.random() * (cfg.range[1] - cfg.range[0]);
|
||||
return Math.round(v * 100) / 100;
|
||||
});
|
||||
return { labels: labels, vals: vals, unit: cfg.unit };
|
||||
} else if (period === '7d') {
|
||||
var now = new Date(2026, 5, 4); // June 4, 2026
|
||||
var labels7 = [];
|
||||
var vals7 = [];
|
||||
for (var i = 6; i >= 0; i--) {
|
||||
var d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
labels7.push((d.getMonth()+1) + '/' + d.getDate());
|
||||
var v = cfg.range[0] + Math.random() * (cfg.range[1] - cfg.range[0]);
|
||||
vals7.push(Math.round(v * 100) / 100);
|
||||
// 普通指标
|
||||
var ranges = {
|
||||
'温度':{range:[18,35],unit:'℃',limitUpper:28,limitLower:16},'湿度':{range:[30,80],unit:'%',limitUpper:70,limitLower:30},
|
||||
'CO₂':{range:[350,1100],unit:'ppm',limitUpper:1000,limitLower:350},'甲醛':{range:[0.02,0.12],unit:'mg/m³',limitUpper:0.1,limitLower:0.02},
|
||||
'PM2.5':{range:[20,150],unit:'μg/m³',limitUpper:75,limitLower:0},'PM10':{range:[40,200],unit:'μg/m³',limitUpper:150,limitLower:0},
|
||||
'太阳辐射':{range:[200,950],unit:'W/m²'},'照度':{range:[20000,95000],unit:'lm/m²'},
|
||||
'风速':{range:[1,8],unit:'m/s'},
|
||||
'榆属':{range:[5,200],unit:'粒/千mm²'},'松属':{range:[5,150],unit:'粒/千mm²'},
|
||||
'杨属':{range:[8,280],unit:'粒/千mm²'},'柏科':{range:[3,110],unit:'粒/千mm²'},
|
||||
'柳属':{range:[5,170],unit:'粒/千mm²'},'蒿属':{range:[2,90],unit:'粒/千mm²'},
|
||||
'藜科':{range:[2,55],unit:'粒/千mm²'},'苋科':{range:[1,38],unit:'粒/千mm²'},
|
||||
'花粉总数':{range:[50,1100],unit:'粒/千mm²'},
|
||||
'电导率':{range:[200,1700],unit:'μS/cm'},'PH':{range:[6.0,9.5],unit:''},
|
||||
'盐度':{range:[100,950],unit:'mg/L'},'电阻率':{range:[400,3200],unit:'Ω·m'},
|
||||
'TDS':{range:[100,850],unit:'mg/L'}
|
||||
};
|
||||
|
||||
if (configs[metricName]) return { metricName: metricName, cfg: configs[metricName], period: period };
|
||||
|
||||
var r = ranges[metricName];
|
||||
if (!r) r = { range: [10, 100], unit: '' };
|
||||
|
||||
var genPoints = function() {
|
||||
var pts = [];
|
||||
for (var i = 0; i < 12; i++) {
|
||||
pts.push(+(r.range[0] + Math.random() * (r.range[1] - r.range[0])).toFixed(2));
|
||||
}
|
||||
return { labels: labels7, vals: vals7, unit: cfg.unit };
|
||||
} else {
|
||||
var labels30 = [];
|
||||
var vals30 = [];
|
||||
for (var i = 0; i < 4; i++) {
|
||||
labels30.push('第' + (i+1) + '周');
|
||||
var v = cfg.range[0] + Math.random() * (cfg.range[1] - cfg.range[0]);
|
||||
vals30.push(Math.round(v * 100) / 100);
|
||||
return pts;
|
||||
};
|
||||
var genPoints7 = function() {
|
||||
var pts = [];
|
||||
for (var i = 0; i < 7; i++) {
|
||||
pts.push(+(r.range[0] + Math.random() * (r.range[1] - r.range[0])).toFixed(2));
|
||||
}
|
||||
return { labels: labels30, vals: vals30, unit: cfg.unit };
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 Y 坐标
|
||||
function valToY(val, yMax, chartHeight, chartTop) {
|
||||
return chartTop + chartHeight - (val / yMax) * chartHeight;
|
||||
}
|
||||
|
||||
// 渲染 SVG 折线图
|
||||
function renderLineChart(metricName, baseVal, period) {
|
||||
var data = genTrend(metricName, baseVal, period);
|
||||
var labels = data.labels;
|
||||
var vals = data.vals;
|
||||
var unit = data.unit;
|
||||
var n = vals.length;
|
||||
|
||||
// 图表尺寸
|
||||
var chartLeft = 40, chartRight = 330, chartWidth = chartRight - chartLeft;
|
||||
var chartTop = 15, chartBottom = 140, chartHeight = chartBottom - chartTop;
|
||||
var yMax = Math.max.apply(null, vals) * 1.2;
|
||||
// 整数化 Y 轴
|
||||
yMax = Math.ceil(yMax);
|
||||
|
||||
// Y 轴刻度
|
||||
return pts;
|
||||
};
|
||||
var genPoints30 = function() {
|
||||
var pts = [];
|
||||
for (var i = 0; i < 30; i++) {
|
||||
pts.push(+(r.range[0] + Math.random() * (r.range[1] - r.range[0])).toFixed(2));
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
var maxVal = Math.max(r.range[1]);
|
||||
var yTicks = 5;
|
||||
var gridLines = '';
|
||||
for (var i = 0; i <= yTicks; i++) {
|
||||
var tickVal = (yMax / yTicks * i).toFixed(unit === 'mg/m³' || unit === 'm/s' ? 2 : 0);
|
||||
var y = valToY(parseFloat(tickVal), yMax, chartHeight, chartTop);
|
||||
gridLines += '<line x1="' + chartLeft + '" y1="' + y + '" x2="' + chartRight + '" y2="' + y + '" class="chart-grid-line"/>';
|
||||
gridLines += '<text x="' + (chartLeft - 4) + '" y="' + (y + 3) + '" class="chart-axis-label" text-anchor="end">' + tickVal + '</text>';
|
||||
}
|
||||
|
||||
// X 轴坐标
|
||||
return {
|
||||
metricName: metricName,
|
||||
isAQI: false, yMax: maxVal, yTicks: Array.from({length: yTicks+1}, function(_, i) {
|
||||
var v = maxVal / yTicks * i;
|
||||
return r.unit === 'mg/m³' ? +v.toFixed(2) : Math.round(v);
|
||||
}),
|
||||
limitUpper: r.limitUpper || null, limitLower: r.limitLower || null,
|
||||
levelBands: null,
|
||||
'24h': { points: genPoints() },
|
||||
'7d': { points: genPoints7() },
|
||||
'30d': { points: genPoints30() }
|
||||
};
|
||||
}
|
||||
|
||||
/* X 轴标签 */
|
||||
var xLabels24 = ['00:00','02:00','04:00','06:00','08:00','10:00','12:00','14:00','16:00','18:00','20:00','22:00'];
|
||||
function getXLabels7() {
|
||||
var labels = [], now = new Date(2026, 5, 4);
|
||||
for (var i = 6; i >= 0; i--) {
|
||||
var d = new Date(now); d.setDate(d.getDate() - i);
|
||||
labels.push((d.getMonth()+1) + '/' + d.getDate());
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/* X 坐标计算 */
|
||||
function calcXPositions(count) {
|
||||
var positions = [], step = chartWidth / (count - 1);
|
||||
for (var i = 0; i < count; i++) positions.push(chartLeft + step * i);
|
||||
return positions;
|
||||
}
|
||||
|
||||
/* 渲染 AQI 柱状堆积图 */
|
||||
function renderAQIBarChart(timeData, labels) {
|
||||
var barData = timeData.barData;
|
||||
var barCount = barData.length;
|
||||
var barGap = Math.max(2, Math.floor(chartWidth / (barCount * 6)));
|
||||
var barWidth = Math.max(6, Math.floor((chartWidth - barGap * (barCount + 1)) / barCount));
|
||||
var xPositions = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
xPositions.push(chartLeft + (chartWidth / (n - 1)) * i);
|
||||
for (var i = 0; i < barCount; i++) {
|
||||
xPositions.push(chartLeft + barGap + i * (barWidth + barGap) + Math.floor(barWidth / 2));
|
||||
}
|
||||
|
||||
// X 轴标签 - 点数多时只显示部分
|
||||
// Y 轴:0-24 小时
|
||||
var yTicks24 = [24, 18, 12, 6, 0];
|
||||
var gridLines = '';
|
||||
for (var i = 0; i < yTicks24.length; i++) {
|
||||
var y = chartBottom - (yTicks24[i] / 24) * chartHeight;
|
||||
gridLines += '<line x1="' + chartLeft + '" y1="' + y + '" x2="' + chartRight + '" y2="' + y + '" class="chart-grid-line"/>';
|
||||
gridLines += '<text x="' + (chartLeft - 4) + '" y="' + (y + 3) + '" class="chart-axis-label" text-anchor="end">' + yTicks24[i] + 'h</text>';
|
||||
}
|
||||
|
||||
// X 轴标签
|
||||
var xLabelsHtml = '';
|
||||
var skip = n > 12 ? Math.ceil(n / 6) : 1;
|
||||
for (var i = 0; i < n; i++) {
|
||||
if (i % skip === 0 || i === n - 1) {
|
||||
var skip = barCount > 16 ? Math.floor(barCount / 7) : 1;
|
||||
for (var i = 0; i < labels.length; i++) {
|
||||
if (i < xPositions.length && i % skip === 0) {
|
||||
xLabelsHtml += '<text x="' + xPositions[i] + '" y="' + (chartBottom + 14) + '" class="chart-axis-label" text-anchor="middle">' + labels[i] + '</text>';
|
||||
}
|
||||
}
|
||||
|
||||
// 折线 + 面积填充
|
||||
// 柱状堆积
|
||||
var barsHtml = '';
|
||||
for (var i = 0; i < barCount; i++) {
|
||||
var bd = barData[i];
|
||||
var x = xPositions[i] - Math.floor(barWidth / 2);
|
||||
var segments = [
|
||||
{val: bd.heavy, color:'#FF4D4F', name:'重污染'},
|
||||
{val: bd.moderate, color:'#FA541C', name:'中污染'},
|
||||
{val: bd.light, color:'#FAAD14', name:'轻污染'},
|
||||
{val: bd.good, color:'#52C41A', name:'未污染'},
|
||||
{val: bd.clean, color:'#13C2C2', name:'清洁'}
|
||||
];
|
||||
var yBase = chartBottom;
|
||||
for (var j = 0; j < segments.length; j++) {
|
||||
var seg = segments[j];
|
||||
if (seg.val > 0) {
|
||||
var segH = (seg.val / 24) * chartHeight;
|
||||
var segY = yBase - segH;
|
||||
barsHtml += '<rect x="' + x + '" y="' + segY + '" width="' + barWidth + '" height="' + segH + '" fill="' + seg.color + '" opacity="0.9" ' +
|
||||
'data-idx="' + i + '" data-level="' + seg.name + '" data-val="' + seg.val + '" ' +
|
||||
'onmouseenter="showBarTip(event,this)" onmouseleave="hideTrendTip()"/>';
|
||||
yBase = segY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图例
|
||||
var legendY = chartBottom + 22;
|
||||
var legendHtml = '<g transform="translate(' + chartLeft + ',' + legendY + ')">';
|
||||
var lx = 0;
|
||||
for (var i = 0; i < aqiLevels.length; i++) {
|
||||
var lv = aqiLevels[i];
|
||||
legendHtml += '<rect x="' + lx + '" y="0" width="10" height="10" fill="' + lv.color + '" rx="1.5"/>';
|
||||
legendHtml += '<text x="' + (lx + 14) + '" y="9" fill="#999" font-size="9">' + lv.name + '</text>';
|
||||
lx += 52;
|
||||
}
|
||||
legendHtml += '</g>';
|
||||
|
||||
var svg = '<svg viewBox="0 0 350 175" preserveAspectRatio="xMidYMid meet">' +
|
||||
gridLines + xLabelsHtml +
|
||||
'<line x1="' + chartLeft + '" y1="' + chartBottom + '" x2="' + chartRight + '" y2="' + chartBottom + '" class="chart-x-axis"/>' +
|
||||
barsHtml + legendHtml + '</svg>';
|
||||
|
||||
window._trendTooltipData = { key: 'aqiBar', barData: barData, labels: labels, xPositions: xPositions };
|
||||
return svg;
|
||||
}
|
||||
|
||||
/* 渲染趋势图主函数 */
|
||||
function renderTrend(metricName, period) {
|
||||
var gen = genTrend(metricName, period);
|
||||
var cfg = gen.cfg || gen;
|
||||
var timeData = cfg[period];
|
||||
if (!timeData) return '';
|
||||
|
||||
// AQI 非 24h → 柱状堆积图
|
||||
if (cfg.isAQI && period !== '24h') {
|
||||
var labels = period === '7d' ? getXLabels7() : Array.from({length:4}, function(_, i) { return '第' + (i+1) + '周'; });
|
||||
return renderAQIBarChart(timeData, labels);
|
||||
}
|
||||
|
||||
var points = timeData.points;
|
||||
var n = points.length;
|
||||
var labels = cfg.isAQI ? (period === '24h' ? xLabels24 : getXLabels7()) : (period === '24h' ? xLabels24 : getXLabels7());
|
||||
if (period === '30d' && n === 30) labels = (function() { var l = [], now = new Date(2026,5,4); for (var i = 29; i >= 0; i--) { var d = new Date(now); d.setDate(d.getDate() - i); l.push((d.getMonth()+1) + '/' + d.getDate()); } return l; })();
|
||||
|
||||
var xPositions = calcXPositions(n);
|
||||
var yMax = cfg.yMax;
|
||||
var yTicks = cfg.yTicks;
|
||||
|
||||
// 网格线
|
||||
var gridLines = '';
|
||||
for (var i = 0; i < yTicks.length; i++) {
|
||||
var y = valToY(yTicks[i], yMax);
|
||||
gridLines += '<line x1="' + chartLeft + '" y1="' + y + '" x2="' + chartRight + '" y2="' + y + '" class="chart-grid-line"/>';
|
||||
gridLines += '<text x="' + (chartLeft - 4) + '" y="' + (y + 3) + '" class="chart-axis-label" text-anchor="end">' + yTicks[i] + '</text>';
|
||||
}
|
||||
|
||||
// X 轴标签
|
||||
var xLabelsHtml = '';
|
||||
var skip = n > 16 ? Math.floor(n / 7) : 1;
|
||||
for (var i = 0; i < labels.length; i++) {
|
||||
if (i < xPositions.length && i % skip === 0) {
|
||||
xLabelsHtml += '<text x="' + xPositions[i] + '" y="' + (chartBottom + 14) + '" class="chart-axis-label" text-anchor="middle">' + labels[i] + '</text>';
|
||||
}
|
||||
}
|
||||
|
||||
// AQI 等级背景
|
||||
var levelBandsHtml = '';
|
||||
if (cfg.isAQI && cfg.levelBands) {
|
||||
for (var i = 0; i < cfg.levelBands.length; i++) {
|
||||
var b = cfg.levelBands[i];
|
||||
levelBandsHtml += '<rect x="' + chartLeft + '" y="' + b.yMin + '" width="' + chartWidth + '" height="' + (b.yMax - b.yMin) + '" fill="' + b.color + '" opacity="' + b.opacity + '"/>';
|
||||
levelBandsHtml += '<text x="' + (chartLeft + 6) + '" y="' + (b.yMin + (b.yMax - b.yMin) / 2 + 4) + '" fill="' + b.color + '" font-size="9" font-weight="500">' + b.label + '</text>';
|
||||
}
|
||||
}
|
||||
|
||||
// 超标区域和虚线
|
||||
var limitHtml = '';
|
||||
if (cfg.limitUpper) {
|
||||
var upperY = valToY(cfg.limitUpper, yMax);
|
||||
var lowerY = valToY(cfg.limitLower, yMax);
|
||||
if (upperY > chartTop) {
|
||||
limitHtml += '<rect x="' + chartLeft + '" y="' + chartTop + '" width="' + chartWidth + '" height="' + (upperY - chartTop) + '" class="chart-limit-exceed"/>';
|
||||
}
|
||||
if (lowerY < chartBottom) {
|
||||
limitHtml += '<rect x="' + chartLeft + '" y="' + lowerY + '" width="' + chartWidth + '" height="' + (chartBottom - lowerY) + '" class="chart-limit-exceed"/>';
|
||||
}
|
||||
limitHtml += '<line x1="' + chartLeft + '" y1="' + upperY + '" x2="' + chartRight + '" y2="' + upperY + '" class="chart-limit-line"/>';
|
||||
limitHtml += '<line x1="' + chartLeft + '" y1="' + lowerY + '" x2="' + chartRight + '" y2="' + lowerY + '" class="chart-limit-line"/>';
|
||||
limitHtml += '<text x="' + (chartLeft + 4) + '" y="' + (upperY - 3) + '" class="chart-limit-label">上限 ' + cfg.limitUpper + '</text>';
|
||||
limitHtml += '<text x="' + (chartLeft + 4) + '" y="' + (lowerY + 12) + '" class="chart-limit-label">下限 ' + cfg.limitLower + '</text>';
|
||||
}
|
||||
|
||||
// 折线 + 数据点
|
||||
var pathD = '';
|
||||
var polylinePoints = '';
|
||||
var dotsHtml = '';
|
||||
var dotR = n > 20 ? 2.5 : 3.5;
|
||||
var unit = cfg.isAQI ? '' : (gen.unit || '');
|
||||
window._trendTooltipData = { key: 'line', points: points, labels: labels, unit: unit, isAQI: cfg.isAQI, xPositions: xPositions };
|
||||
for (var i = 0; i < n; i++) {
|
||||
var y = valToY(vals[i], yMax, chartHeight, chartTop);
|
||||
var y = valToY(points[i], yMax);
|
||||
var x = xPositions[i];
|
||||
pathD += (i === 0 ? 'M' : 'L') + x + ',' + y + ' ';
|
||||
polylinePoints += x + ',' + y + ' ';
|
||||
dotsHtml += '<circle cx="' + x + '" cy="' + y + '" r="' + dotR + '" class="chart-dot" ' +
|
||||
var dotColor = cfg.isAQI ? getAqiColor(points[i]) : chartLine;
|
||||
dotsHtml += '<circle cx="' + x + '" cy="' + y + '" r="' + dotR + '" class="chart-dot" style="fill:' + dotColor + ';cursor:pointer;" ' +
|
||||
'data-idx="' + i + '" onmouseenter="showTrendTip(event,this)" onmouseleave="hideTrendTip()"/>';
|
||||
}
|
||||
// 面积闭合路径
|
||||
pathD += 'L' + xPositions[n-1] + ',' + chartBottom + ' L' + xPositions[0] + ',' + chartBottom + ' Z';
|
||||
|
||||
var svg = '<svg viewBox="0 0 350 165" preserveAspectRatio="xMidYMid meet">' +
|
||||
@@ -363,32 +540,65 @@ function renderLineChart(metricName, baseVal, period) {
|
||||
'<stop offset="0%" stop-color="#1aad8d" stop-opacity="0.25"/>' +
|
||||
'<stop offset="100%" stop-color="#1aad8d" stop-opacity="0.02"/>' +
|
||||
'</linearGradient></defs>' +
|
||||
gridLines +
|
||||
xLabelsHtml +
|
||||
gridLines + xLabelsHtml +
|
||||
'<line x1="' + chartLeft + '" y1="' + chartBottom + '" x2="' + chartRight + '" y2="' + chartBottom + '" class="chart-x-axis"/>' +
|
||||
'<path d="' + pathD + '" fill="url(#trendAreaGrad)"/>' +
|
||||
levelBandsHtml + limitHtml +
|
||||
'<path class="chart-area-fill" d="' + pathD + '"/>' +
|
||||
'<polyline class="chart-line" points="' + polylinePoints.trim() + '"/>' +
|
||||
dotsHtml +
|
||||
'</svg>';
|
||||
|
||||
// 存储 tooltip 数据
|
||||
window._trendTooltipData = { labels: labels, vals: vals, unit: unit, xPositions: xPositions, yMax: yMax, chartHeight: chartHeight, chartTop: chartTop };
|
||||
dotsHtml + '</svg>';
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// 显示数据点 Tip
|
||||
/* 显示折线图数据点 Tip */
|
||||
function showTrendTip(e, el) {
|
||||
var td = window._trendTooltipData;
|
||||
if (!td) return;
|
||||
var idx = parseInt(el.getAttribute('data-idx'));
|
||||
var dateStr = td.labels[idx] || '';
|
||||
var val = td.vals[idx];
|
||||
var val = td.points[idx];
|
||||
var unit = td.unit || '';
|
||||
var color = td.isAQI ? getAqiColor(val) : chartLine;
|
||||
document.getElementById('tooltipDate').textContent = dateStr;
|
||||
document.getElementById('tooltipBody').innerHTML =
|
||||
'<span class="chart-tooltip__val">' + val + '</span>' +
|
||||
'<span class="chart-tooltip__label">' + (unit ? ' ' + unit : '') + '</span>';
|
||||
'<div class="chart-tooltip__row">' +
|
||||
'<span class="chart-tooltip__dot" style="background:' + color + '"></span>' +
|
||||
'<span class="chart-tooltip__label">数值</span>' +
|
||||
'<span class="chart-tooltip__val">' + val + (unit ? ' ' + unit : '') + '</span></div>';
|
||||
showTooltip(el);
|
||||
}
|
||||
|
||||
/* 显示柱状图 Tip - 显示当天所有等级占比 */
|
||||
function showBarTip(e, el) {
|
||||
var td = window._trendTooltipData;
|
||||
if (!td) return;
|
||||
var idx = parseInt(el.getAttribute('data-idx'));
|
||||
var bd = td.barData[idx];
|
||||
if (!bd) return;
|
||||
var dateStr = td.labels[idx] || '';
|
||||
document.getElementById('tooltipDate').textContent = dateStr;
|
||||
var rows = '';
|
||||
var levels = [
|
||||
{name:'清洁', color:'#13C2C2', val:bd.clean},
|
||||
{name:'未污染', color:'#52C41A', val:bd.good},
|
||||
{name:'轻污染', color:'#FAAD14', val:bd.light},
|
||||
{name:'中污染', color:'#FA541C', val:bd.moderate},
|
||||
{name:'重污染', color:'#FF4D4F', val:bd.heavy}
|
||||
];
|
||||
for (var i = 0; i < levels.length; i++) {
|
||||
var lv = levels[i];
|
||||
var pct = Math.round(lv.val / 24 * 100);
|
||||
rows += '<div class="chart-tooltip__row">' +
|
||||
'<span class="chart-tooltip__dot" style="background:' + lv.color + '"></span>' +
|
||||
'<span class="chart-tooltip__label">' + lv.name + '</span>' +
|
||||
'<span class="chart-tooltip__val">' + pct + '%</span></div>';
|
||||
}
|
||||
document.getElementById('tooltipBody').innerHTML = rows;
|
||||
showTooltip(el);
|
||||
}
|
||||
|
||||
/* 统一 Tooltip 定位 */
|
||||
function showTooltip(el) {
|
||||
var tip = document.getElementById('chartTooltip');
|
||||
tip.classList.add('show');
|
||||
var rect = el.getBoundingClientRect();
|
||||
@@ -398,9 +608,11 @@ function showTrendTip(e, el) {
|
||||
var top = rect.top - tipH - 10;
|
||||
if (left < 8) left = 8;
|
||||
if (left + tipW > window.innerWidth - 8) left = window.innerWidth - tipW - 8;
|
||||
if (top < 8) top = rect.bottom + 10;
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function hideTrendTip() {
|
||||
document.getElementById('chartTooltip').classList.remove('show');
|
||||
}
|
||||
@@ -444,12 +656,10 @@ function buildGauge(levelIdx, total, color) {
|
||||
}
|
||||
|
||||
function renderChart(metricIdx, period) {
|
||||
// metricIdx === -1 表示"空气质量指数"虚拟指标
|
||||
if (metricIdx === -1) {
|
||||
// 用设备各指标综合计算一个模拟 AQI 值
|
||||
return renderLineChart('空气质量指数', 55, period);
|
||||
return renderTrend('空气质量指数', period);
|
||||
}
|
||||
return renderLineChart(dev.metrics[metricIdx].k, parseFloat(dev.metrics[metricIdx].v) || 50, period);
|
||||
return renderTrend(dev.metrics[metricIdx].k, period);
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
||||
Reference in New Issue
Block a user