airpredict/apps/web/public/dashboard/dashboard.js

261 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* dashboard.js — themes + renderDashboard(themeKey, opts) -> HTML string.
Pure markup; charts are SVG from window.CHARTS. window.renderDashboard */
(function () {
const C = window.CHARTS;
const D = window.DASH_DATA;
const SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',system-ui,sans-serif";
const SERIF = "'Songti SC','STSong',Georgia,'Times New Roman',serif";
const ICON = {
grid: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>',
predict: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 17l5-6 4 3 6-8"/><path d="M3 21h18"/></svg>',
flask: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v6l-5 8a2 2 0 0 0 1.7 3h10.6A2 2 0 0 0 19 17l-5-8V3"/><path d="M7 14h10"/></svg>',
source: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></svg>',
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
report: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1l2-1.6-2-3.4-2.3 1a7 7 0 0 0-1.7-1l-.3-2.5h-4l-.3 2.5a7 7 0 0 0-1.7 1l-2.3-1-2 3.4 2 1.6a7 7 0 0 0 0 2l-2 1.6 2 3.4 2.3-1a7 7 0 0 0 1.7 1l.3 2.5h4l.3-2.5a7 7 0 0 0 1.7-1l2.3 1 2-3.4-2-1.6c.1-.3.1-.7.1-1z" stroke-linejoin="round"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
bell: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6"/><path d="M10 20a2 2 0 0 0 4 0"/></svg>',
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l9 16H3z"/><path d="M12 9v5M12 17.5v.01"/></svg>',
bldg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M4 21V5a1 1 0 0 1 1-1h8a1 1 0 0 1 1 1v16M14 21V9h5a1 1 0 0 1 1 1v11M7 8h2M7 12h2M7 16h2"/></svg>',
leaf: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20c10 2 16-4 16-14 0 0-8-2-12 2-3 3-3 7-1 9 3-4 6-6 9-7"/></svg>',
up: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14l6-6 6 6"/></svg>',
down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M6 10l6 6 6-6"/></svg>',
};
const THEMES = {
dark: {
cls: 'v-dark',
vars: {
'--bg': '#0a101e', '--side-bg': '#070c17', '--side-border': 'rgba(255,255,255,.06)',
'--side-ink': '#e7eef9', '--side-sub': '#7589a6', '--side-hover': 'rgba(255,255,255,.05)',
'--border': 'rgba(255,255,255,.08)', '--panel': '#111b2e', '--panel2': '#0d1626',
'--ink': '#e8eff9', '--sub': '#8ba0bd', '--faint': '#5d6f8c',
'--accent': '#2dd4bf', '--accent2': '#38bdf8', '--accent-soft': 'rgba(45,212,191,.14)', '--accent-on': '#5eead4',
'--good': '#34d399', '--good-soft': 'rgba(52,211,153,.15)', '--warn': '#fbbf24', '--warn-soft': 'rgba(251,191,36,.15)',
'--bad': '#f87171', '--bad-soft': 'rgba(248,113,113,.15)', '--track': 'rgba(255,255,255,.07)',
'--radius': '16px', '--shadow': '0 10px 34px rgba(0,0,0,.45)', '--topbar-bg': 'rgba(7,12,23,.5)',
'--logo-glow': '0 0 18px rgba(45,212,191,.5)', '--font': SANS, '--display': SANS,
},
chart: { good: '#34d399', warn: '#fbbf24', bad: '#f87171', badSoft: 'rgba(248,113,113,.2)', accent: '#2dd4bf', grid: 'rgba(255,255,255,.08)', ink: '#e8eff9', sub: '#8ba0bd', faint: '#5d6f8c', track: 'rgba(255,255,255,.09)', glow: true, gid: 'dk' },
},
light: {
cls: 'v-light',
vars: {
'--bg': '#eef1f7', '--side-bg': '#ffffff', '--side-border': '#e7ebf2',
'--side-ink': '#16212f', '--side-sub': '#6a788b', '--side-hover': 'rgba(20,87,214,.06)',
'--border': '#e7ebf2', '--panel': '#ffffff', '--panel2': '#f4f6fb',
'--ink': '#15212e', '--sub': '#5b6b7d', '--faint': '#98a4b3',
'--accent': '#1457d6', '--accent2': '#3b82f6', '--accent-soft': 'rgba(20,87,214,.10)', '--accent-on': '#1457d6',
'--good': '#16a34a', '--good-soft': 'rgba(22,163,74,.11)', '--warn': '#e08600', '--warn-soft': 'rgba(224,134,0,.13)',
'--bad': '#dc2626', '--bad-soft': 'rgba(220,38,38,.10)', '--track': '#eaeef4',
'--radius': '14px', '--shadow': '0 1px 2px rgba(20,30,50,.05),0 6px 18px rgba(20,30,50,.05)', '--topbar-bg': 'rgba(255,255,255,.7)',
'--logo-glow': 'none', '--font': SANS, '--display': SANS,
},
chart: { good: '#16a34a', warn: '#e08600', bad: '#dc2626', badSoft: 'rgba(220,38,38,.1)', accent: '#1457d6', grid: '#eaeef4', ink: '#15212e', sub: '#5b6b7d', faint: '#98a4b3', track: '#eaeef4', glow: false, gid: 'lt' },
},
warm: {
cls: 'v-warm',
vars: {
'--bg': '#f4f0e7', '--side-bg': '#fffdf8', '--side-border': '#e8e0d0',
'--side-ink': '#241e15', '--side-sub': '#897f6c', '--side-hover': 'rgba(31,122,90,.08)',
'--border': '#e9e1d2', '--panel': '#fffdf8', '--panel2': '#f4efe3',
'--ink': '#221d15', '--sub': '#6c6353', '--faint': '#a89c86',
'--accent': '#1f7a5a', '--accent2': '#2f9e74', '--accent-soft': 'rgba(31,122,90,.12)', '--accent-on': '#1f7a5a',
'--good': '#2f8f5b', '--good-soft': 'rgba(47,143,91,.13)', '--warn': '#ca8326', '--warn-soft': 'rgba(202,131,38,.15)',
'--bad': '#bf4a30', '--bad-soft': 'rgba(191,74,48,.12)', '--track': '#ebe3d4',
'--radius': '16px', '--shadow': '0 1px 2px rgba(60,50,30,.05)', '--topbar-bg': 'rgba(255,253,248,.7)',
'--logo-glow': 'none', '--font': SANS, '--display': SERIF,
},
chart: { good: '#2f8f5b', warn: '#ca8326', bad: '#bf4a30', badSoft: 'rgba(191,74,48,.14)', accent: '#1f7a5a', grid: '#ece4d5', ink: '#221d15', sub: '#6c6353', faint: '#a89c86', track: '#ebe3d4', glow: false, gid: 'wm' },
},
};
const pct = (v, t) => Math.round((v / t) * 1000) / 10;
const statusKey = r => r >= 1 ? 'bad' : r >= 0.85 ? 'warn' : 'good';
const cnLevel = { bad: '超标', warn: '临界', good: '达标' };
function nav(active) {
const items = [
['grid', '总览看板', 0], ['predict', '污染物预测', 0], ['flask', '材料库', 0],
['source', '污染源识别', 19], ['folder', '案例库 / 项目', 0], ['report', '检测报告', 0],
];
return `<div class="d-navgrp">主菜单</div><div class="d-nav">` +
items.map((it, i) => `<div class="d-nav-i${i === active ? ' on' : ''}">${ICON[it[0]]}<span>${it[1]}</span>${it[2] ? `<span class="d-badge">${it[2]}</span>` : ''}</div>`).join('') +
`</div>`;
}
function kpiCard(lab, icon, iconBg, iconCol, num, unit, trend) {
return `<div class="d-kpi">
<div class="d-kpi-lab"><span class="d-kpi-ic" style="background:${iconBg};color:${iconCol}">${ICON[icon]}</span>${lab}</div>
<div class="d-kpi-row">
<div><span class="d-kpi-num">${num}</span>${unit ? `<span class="d-kpi-unit">${unit}</span>` : ''}</div>
${trend || ''}
</div></div>`;
}
// pollutant ring gauges or bars
function pollutantViz(mode, p) {
if (mode === 'bar') {
const w = 470, rowH = 34, padR = 64, barX = 92, barW = w - barX - padR;
let rows = '';
D.pollutants.forEach((d, i) => {
const ratio = d.value / d.limit, col = C.status(ratio, p);
const yy = i * rowH;
const limX = barX + barW; // 100% = limit
const bw = Math.max(4, Math.min(ratio, 1.45) / 1.45 * barW);
const limMark = barX + (1 / 1.45) * barW;
rows += `<g transform="translate(0 ${yy})">
<text x="0" y="${rowH / 2 + 4}" font-size="12" font-weight="700" fill="${p.ink}">${d.name}</text>
<rect x="${barX}" y="${rowH / 2 - 8}" width="${barW}" height="16" rx="5" fill="${p.track}"/>
<rect x="${barX}" y="${rowH / 2 - 8}" width="${bw.toFixed(1)}" height="16" rx="5" fill="${col}"/>
<line x1="${limMark.toFixed(1)}" y1="${rowH / 2 - 12}" x2="${limMark.toFixed(1)}" y2="${rowH / 2 + 12}" stroke="${p.bad}" stroke-width="1.4" stroke-dasharray="3 3"/>
<text x="${w}" y="${rowH / 2 + 4}" text-anchor="end" font-size="11" font-weight="700" fill="${col}" style="font-variant-numeric:tabular-nums">${C.fmt(d.value, d.value >= 10 ? 0 : 3)}</text>
</g>`;
});
return `<div style="padding-top:4px"><svg viewBox="0 0 ${w} ${D.pollutants.length * rowH + 6}" width="100%">${rows}
<text x="${barX + (1 / 1.45) * barW}" y="${D.pollutants.length * rowH + 2}" text-anchor="middle" font-size="9" fill="${p.bad}">国标限值</text></svg></div>`;
}
return `<div class="d-gauges">` + D.pollutants.map(d => {
const ratio = d.value / d.limit, sk = statusKey(ratio);
return `<div class="d-g">${C.ringGauge({ value: d.value, limit: d.limit, unit: d.unit, name: d.name, en: d.en, p })}
<div class="d-g-nm">${d.name}</div><div class="d-g-en">${d.en} · 限值 ${C.fmt(d.limit, 2)}</div>
<div class="d-g-pill pill-${sk}">${cnLevel[sk]} ${Math.round(ratio * 100)}%</div></div>`;
}).join('') + `</div>`;
}
// compliance donut or stacked bar
function complianceViz(mode, p, total) {
const segs = D.compliance.map(s => ({ label: s.label, value: s.value, color: p[s.key] }));
const legend = `<div class="d-legend">` + segs.map(s =>
`<div class="d-leg"><span class="dot" style="background:${s.color}"></span><span class="nm">${s.label}房间</span><span class="vl">${s.value}</span><span class="pc">${pct(s.value, total)}%</span></div>`
).join('') + `</div>`;
if (mode === 'bar') {
let x = 0; const w = 100;
const segbar = segs.map(s => { const wpc = s.value / total * w; const r = `<div style="width:${wpc}%;background:${s.color}"></div>`; x += wpc; return r; }).join('');
return `<div style="display:flex;height:18px;border-radius:6px;overflow:hidden;gap:2px;margin:6px 0 16px">${segbar}</div>
<div style="font-family:var(--display);font-size:34px;font-weight:800;letter-spacing:-.5px">${D.kpis.compliance}<span style="font-size:16px;color:var(--faint)">%</span></div>
<div style="font-size:12px;color:var(--sub);margin:2px 0 4px">总体房间达标率</div>${legend}`;
}
return `<div style="display:flex;justify-content:center;margin:4px 0 10px">${C.donut({ segments: segs, centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p })}</div>${legend}`;
}
window.renderDashboard = function (themeKey, opts) {
opts = opts || {};
const T = THEMES[themeKey] || THEMES.dark;
const p = T.chart;
const styleVars = Object.entries(T.vars).map(([k, v]) => `${k}:${v}`).join(';');
const warm = themeKey === 'warm';
// worst pollutant flag for latest prediction (主卧 甲醛)
const hcho = D.pollutants[0];
const over = Math.round((hcho.value / hcho.limit - 1) * 100);
const top = `<div class="d-top">
<div><div class="d-top-tt">工程污染概览</div><div class="d-top-crumb">${D.project.name} · ${D.project.area}㎡ · ${D.project.type}</div></div>
<div class="d-spacer"></div>
<div class="d-search">${ICON.search}<span>搜索项目 / 房间 / 材料</span></div>
<div class="d-std"><b class="on">GB/T 18883</b><b>GB 50325</b></div>
<div class="d-iconbtn">${ICON.bell}<span class="d-dot"></span></div>
</div>`;
const side = `<div class="d-side">
<div class="d-brand"><div class="d-logo">${warm ? ICON.leaf : ICON.bldg}</div>
<div><div class="d-brand-tt">污染物预测系统</div><div class="d-brand-sub">INDOOR · AIR</div></div></div>
${nav(0)}
<div class="d-side-foot">
<div class="d-user"><div class="d-ava">陈</div>
<div><div class="d-user-nm">陈工 · 环境工程师</div><span class="d-pro">★ 专业版</span></div></div>
</div></div>`;
const kpis = `<div class="d-kpis">
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', `<span class="d-kpi-tr d-up">${ICON.up}本周 +3</span>`)}
${kpiCard('房间达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `<span class="d-kpi-tr d-up">${ICON.up}${D.kpis.trend.compliance}%</span>`)}
${kpiCard('当前超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', `<span class="d-kpi-tr d-up">${ICON.down}${Math.abs(D.kpis.trend.exceed)}</span>`)}
${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', `<span class="d-kpi-tr" style="color:var(--accent-on);background:var(--accent-soft)">${ICON.up}活跃</span>`)}
</div>`;
const totalRooms = D.compliance.reduce((s, x) => s + x.value, 0);
const colCard = `<div class="card sp8">
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>各房间 · 甲醛预测浓度</div><div class="card-sub">柱状图按状态着色 · 红=超标 / 橙=临界 / 绿=达标,对照国标限值</div></div>
<span class="card-tag">单位 mg/m³</span></div>
${C.columns({ items: D.rooms, limit: D.roomLimit, limit2: D.roomLimit2, unit: 'mg/m³', p, w: 760, h: 268 })}
</div>`;
const donutCard = `<div class="card sp4">
<div class="card-h"><div class="card-t"><span class="d-bar"></span>项目达标率</div><span class="card-tag">${totalRooms} 间房</span></div>
${complianceViz(opts.compliance || 'donut', p, totalRooms)}
</div>`;
const gaugeCard = `<div class="card sp5">
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>最近一次预测 · 主卧</div><div class="card-sub">${D.project.updated} · 6 项污染物 vs 国标限值</div></div></div>
<div class="d-alert">${ICON.alert}<span>甲醛 ${hcho.value} mg/m³ · 超出 GB/T 18883 限值 ${over}%,建议加强通风并核查人造板材料</span></div>
${pollutantViz(opts.pollutant || 'ring', p)}
</div>`;
const radarCard = `<div class="card sp4">
<div class="card-h"><div class="card-t"><span class="d-bar"></span>污染物雷达</div><span class="card-tag">主卧</span></div>
<div class="card-sub" style="margin:-8px 0 2px">虚线红环 = 国标限值(比值 1.0</div>
${C.radar({ axes: D.pollutants.map(d => ({ name: d.name, value: d.value, limit: d.limit })), p, size: 250 })}
</div>`;
const decayCard = `<div class="card sp3">
<div class="card-h"><div class="card-t"><span class="d-bar"></span>甲醛衰减</div></div>
<div class="card-sub" style="margin:-8px 0 8px">随通风天数</div>
${C.decayArea({ points: D.decay, limit: D.roomLimit, unit: 'mg/m³', p, w: 300, h: 176 })}
</div>`;
const tableRows = D.exceed.map(r => {
const ratio = r.value / r.limit;
return `<tr><td><div class="rm">${r.room}</div><div class="pj">${r.project}</div></td>
<td><span class="d-pol">${r.pollutant}</span></td>
<td class="vn" style="color:${C.status(ratio, p)}">${C.fmt(r.value, r.value >= 10 ? 0 : 3)}</td>
<td class="lm">${C.fmt(r.limit, 2)}</td>
<td><span class="d-chip chip-${r.level}">${cnLevel[r.level]} ${Math.round(ratio * 100)}%</span></td></tr>`;
}).join('');
const tableCard = `<div class="card sp8">
<div class="card-h"><div class="card-t"><span class="d-bar"></span>超标房间清单</div><span class="card-tag">${ICON.alert ? '' : ''}${D.exceed.length} 条 · 按超标率排序</span></div>
<table class="d-tbl"><thead><tr><th>房间 / 项目</th><th>污染物</th><th>预测浓度</th><th>限值</th><th>状态</th></tr></thead>
<tbody>${tableRows}</tbody></table></div>`;
const matCard = `<div class="card sp4">
<div class="card-h"><div><div class="card-t"><span class="d-bar"></span>污染源识别 · 材料贡献</div><div class="card-sub">主卧甲醛 · 公式溯源排行</div></div></div>
${C.hbars({ items: D.materials.map((m, i) => ({ ...m, color: i === 0 ? p.bad : i === 1 ? p.warn : p.accent })), p, w: 340, rowH: 40 })}
</div>`;
let body;
if (warm) {
const heroComp = `<div class="d-hero"><div class="d-hero-l">
${C.donut({ segments: D.compliance.map(s => ({ label: s.label, value: s.value, color: p[s.key] })), centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p, size: 150 })}
<div class="d-hero-txt"><div class="t">在管 ${D.kpis.projects} 个项目 · ${totalRooms} 间房</div>
<div class="n">${D.compliance[2].value} 间超标 · ${D.compliance[1].value} 间临界</div>
<div class="t" style="margin-top:8px">最近更新 ${D.project.updated}</div></div>
</div>
<div class="d-kpis" style="margin:0">
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', '')}
${kpiCard('达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `<span class="d-kpi-tr d-up">${ICON.up}${D.kpis.trend.compliance}</span>`)}
${kpiCard('超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', '')}
${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', '')}
</div></div>`;
body = heroComp + `<div class="d-grid">
${colCard}
${gaugeCard.replace('sp5','sp4')}
${radarCard}
${decayCard.replace('sp3','sp4')}
${matCard}
${tableCard.replace('sp8','sp12')}
</div>`;
} else {
body = kpis + `<div class="d-grid">
${colCard}${donutCard}
${gaugeCard}${radarCard}${decayCard}
${tableCard}${matCard}
</div>`;
}
return `<div class="dash ${T.cls}" style="${styleVars}">${C.defs(p)}${side}
<div class="d-main">${top}<div class="d-scroll">${body}</div></div></div>`;
};
})();