feat: 设备详情页变化趋势改造为折线图+新增空气质量指数
- 变化趋势图表从柱状图改为 SVG 折线图(参考 web-admin indoor-data-analysis.html) - 下拉框新增"空气质量指数"选项并设为默认选中 - 所有指标统一改为折线图方式展示,支持渐变面积填充 - 数据点交互提示(Tooltip),支持近24小时/近7日/近30日切换 - 各指标拥有独立数值范围配置,确保 Y 轴比例合理
This commit is contained in:
@@ -4,6 +4,21 @@
|
||||
|
||||
---
|
||||
|
||||
## [2026-06-04] feat: 设备详情页变化趋势改造为折线图+新增空气质量指数
|
||||
|
||||
**涉及文件(1个修改):**
|
||||
- `environment/device-detail.html` — 变化趋势图表从柱状图改为 SVG 折线图,下拉框新增"空气质量指数"选项并默认选中
|
||||
|
||||
**变更说明:**
|
||||
- 变化趋势图表从柱状图改为 SVG 折线图,参考 web-admin 端 indoor-data-analysis.html 样式
|
||||
- 折线图支持渐变面积填充、数据点交互提示(Tooltip)
|
||||
- 下拉选择框新增"空气质量指数"虚拟指标,并设为默认选中项
|
||||
- 所有原有指标(温度、湿度、PM2.5 等)均改为折线图方式展示
|
||||
- 支持近24小时(12个数据点)和近7日(7个数据点)两种时间维度
|
||||
- 各指标拥有独立的数值范围配置,确保折线图 Y 轴比例合理
|
||||
|
||||
---
|
||||
|
||||
## [2026-05-07] feat: 四个预警历史页面新增时间筛选功能
|
||||
|
||||
**涉及文件(4个修改):**
|
||||
|
||||
@@ -44,13 +44,20 @@ 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; }
|
||||
.bar-chart-outer { position: relative; }
|
||||
.bar-chart { display: flex; align-items: flex-end; justify-content: space-around; height: 110px; border-bottom: 1px solid #f0f0f0; padding: 0 4px; }
|
||||
.bar-col { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||
.bar-val { font-size: 10px; color: #555; font-weight: 600; }
|
||||
.bar-body { width: 16px; border-radius: 4px 4px 0 0; background: linear-gradient(180deg, #36cfc9 0%, #1aad8d 100%); }
|
||||
.bar-x-labels { display: flex; justify-content: space-around; padding: 5px 4px 0; }
|
||||
.bar-x-labels span { font-size: 9px; color: #aaa; flex: 1; text-align: center; }
|
||||
/* 折线图样式 */
|
||||
.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-x-axis { stroke: #e8e8e8; stroke-width: 1; }
|
||||
/* 数据点 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.show { display: block; }
|
||||
.chart-tooltip__label { color: #999; font-size: 11px; }
|
||||
.chart-tooltip__val { font-size: 14px; 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>
|
||||
@@ -107,6 +114,11 @@ body { background: #f0f2f5; display: flex; justify-content: center; align-items:
|
||||
<div id="barChartWrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 数据点 Tip 弹窗 -->
|
||||
<div class="chart-tooltip" id="chartTooltip">
|
||||
<div class="chart-tooltip__label" id="tooltipDate"></div>
|
||||
<div id="tooltipBody"></div>
|
||||
</div>
|
||||
<div class="bottom-btn-wrap">
|
||||
<button class="alarm-btn" onclick="location.href='alarm-data.html?tab='+tab+'&id='+id">报警数据</button>
|
||||
</div>
|
||||
@@ -224,13 +236,173 @@ const ALL_DATA = {
|
||||
};
|
||||
|
||||
// 趋势模拟数据生成
|
||||
function genTrend(base, period) {
|
||||
const labels24 = ['00:00','04:00','08:00','12:00','16:00','20:00','24:00'];
|
||||
const labels7 = ['周一','周二','周三','周四','周五','周六','周日'];
|
||||
const labels30 = ['第1周','第2周','第3周','第4周'];
|
||||
const labels = period==='24h' ? labels24 : period==='7d' ? labels7 : labels30;
|
||||
const vals = labels.map(() => Math.round(base * (0.6 + Math.random() * 0.8)));
|
||||
return { labels, vals };
|
||||
function genTrend(metricName, baseVal, 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' },
|
||||
};
|
||||
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);
|
||||
}
|
||||
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 { 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 轴刻度
|
||||
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 轴坐标
|
||||
var xPositions = [];
|
||||
for (var i = 0; i < n; i++) {
|
||||
xPositions.push(chartLeft + (chartWidth / (n - 1)) * i);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
xLabelsHtml += '<text x="' + xPositions[i] + '" y="' + (chartBottom + 14) + '" class="chart-axis-label" text-anchor="middle">' + labels[i] + '</text>';
|
||||
}
|
||||
}
|
||||
|
||||
// 折线 + 面积填充
|
||||
var pathD = '';
|
||||
var polylinePoints = '';
|
||||
var dotsHtml = '';
|
||||
var dotR = n > 20 ? 2.5 : 3.5;
|
||||
for (var i = 0; i < n; i++) {
|
||||
var y = valToY(vals[i], yMax, chartHeight, chartTop);
|
||||
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" ' +
|
||||
'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">' +
|
||||
'<defs><linearGradient id="trendAreaGrad" x1="0" y1="0" x2="0" y2="1">' +
|
||||
'<stop offset="0%" stop-color="#1aad8d" stop-opacity="0.25"/>' +
|
||||
'<stop offset="100%" stop-color="#1aad8d" stop-opacity="0.02"/>' +
|
||||
'</linearGradient></defs>' +
|
||||
gridLines +
|
||||
xLabelsHtml +
|
||||
'<line x1="' + chartLeft + '" y1="' + chartBottom + '" x2="' + chartRight + '" y2="' + chartBottom + '" class="chart-x-axis"/>' +
|
||||
'<path d="' + pathD + '" fill="url(#trendAreaGrad)"/>' +
|
||||
'<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 };
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// 显示数据点 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 unit = td.unit || '';
|
||||
document.getElementById('tooltipDate').textContent = dateStr;
|
||||
document.getElementById('tooltipBody').innerHTML =
|
||||
'<span class="chart-tooltip__val">' + val + '</span>' +
|
||||
'<span class="chart-tooltip__label">' + (unit ? ' ' + unit : '') + '</span>';
|
||||
var tip = document.getElementById('chartTooltip');
|
||||
tip.classList.add('show');
|
||||
var rect = el.getBoundingClientRect();
|
||||
var tipW = tip.offsetWidth;
|
||||
var tipH = tip.offsetHeight;
|
||||
var left = rect.left - tipW / 2 + rect.width / 2;
|
||||
var top = rect.top - tipH - 10;
|
||||
if (left < 8) left = 8;
|
||||
if (left + tipW > window.innerWidth - 8) left = window.innerWidth - tipW - 8;
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = top + 'px';
|
||||
}
|
||||
function hideTrendTip() {
|
||||
document.getElementById('chartTooltip').classList.remove('show');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -240,7 +412,7 @@ const tabData = ALL_DATA[tab];
|
||||
const dev = tabData.devices.find(d => d.id === id) || tabData.devices[0];
|
||||
const li = dev.level;
|
||||
|
||||
let trendMetricIdx = 0;
|
||||
let trendMetricIdx = -1; // -1 means 空气质量指数 (virtual metric)
|
||||
let trendPeriod = '24h';
|
||||
|
||||
function buildGauge(levelIdx, total, color) {
|
||||
@@ -271,17 +443,13 @@ function buildGauge(levelIdx, total, color) {
|
||||
return `<svg viewBox="0 0 200 110" width="200" height="110">${paths}</svg>`;
|
||||
}
|
||||
|
||||
function renderBarChart(metricIdx, period) {
|
||||
const baseVal = parseFloat(dev.metrics[metricIdx].v) || 50;
|
||||
const { labels, vals } = genTrend(baseVal, period);
|
||||
const maxVal = Math.max(...vals);
|
||||
const maxH = 80;
|
||||
const cols = vals.map((v, i) => {
|
||||
const h = Math.max(6, Math.round((v / maxVal) * maxH));
|
||||
return `<div class="bar-col"><div class="bar-val">${v}</div><div class="bar-body" style="height:${h}px"></div></div>`;
|
||||
}).join('');
|
||||
const xlabels = labels.map(l => `<span>${l}</span>`).join('');
|
||||
return `<div class="bar-chart">${cols}</div><div class="bar-x-labels">${xlabels}</div>`;
|
||||
function renderChart(metricIdx, period) {
|
||||
// metricIdx === -1 表示"空气质量指数"虚拟指标
|
||||
if (metricIdx === -1) {
|
||||
// 用设备各指标综合计算一个模拟 AQI 值
|
||||
return renderLineChart('空气质量指数', 55, period);
|
||||
}
|
||||
return renderLineChart(dev.metrics[metricIdx].k, parseFloat(dev.metrics[metricIdx].v) || 50, period);
|
||||
}
|
||||
|
||||
function render() {
|
||||
@@ -296,22 +464,25 @@ function render() {
|
||||
document.getElementById('metricsGrid').innerHTML = dev.metrics.map(m =>
|
||||
`<div class="metric-item"><div class="metric-item__val">${m.v}<span class="metric-item__unit">${m.u}</span></div><div class="metric-item__label">${m.k}</div></div>`
|
||||
).join('');
|
||||
// 趋势下拉框:增加"空气质量指数"并默认选中
|
||||
const sel = document.getElementById('trendMetricSelect');
|
||||
sel.innerHTML = dev.metrics.map((m, i) => `<option value="${i}">${m.k}</option>`).join('');
|
||||
let opts = '<option value="-1">空气质量指数</option>';
|
||||
opts += dev.metrics.map((m, i) => `<option value="${i}">${m.k}</option>`).join('');
|
||||
sel.innerHTML = opts;
|
||||
sel.value = trendMetricIdx;
|
||||
document.getElementById('barChartWrap').innerHTML = renderBarChart(trendMetricIdx, trendPeriod);
|
||||
document.getElementById('barChartWrap').innerHTML = renderChart(trendMetricIdx, trendPeriod);
|
||||
}
|
||||
|
||||
function onTrendMetricChange(sel) {
|
||||
trendMetricIdx = parseInt(sel.value);
|
||||
document.getElementById('barChartWrap').innerHTML = renderBarChart(trendMetricIdx, trendPeriod);
|
||||
document.getElementById('barChartWrap').innerHTML = renderChart(trendMetricIdx, trendPeriod);
|
||||
}
|
||||
|
||||
function switchTimePeriod(el, period) {
|
||||
trendPeriod = period;
|
||||
document.querySelectorAll('.time-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
document.getElementById('barChartWrap').innerHTML = renderBarChart(trendMetricIdx, trendPeriod);
|
||||
document.getElementById('barChartWrap').innerHTML = renderChart(trendMetricIdx, trendPeriod);
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
Reference in New Issue
Block a user