/* 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: '', predict: '', flask: '', source: '', folder: '', report: '', gear: '', search: '', bell: '', alert: '', bldg: '', leaf: '', up: '', down: '', }; 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 `
主菜单
` + items.map((it, i) => `
${ICON[it[0]]}${it[1]}${it[2] ? `${it[2]}` : ''}
`).join('') + `
`; } function kpiCard(lab, icon, iconBg, iconCol, num, unit, trend) { return `
${ICON[icon]}${lab}
${num}${unit ? `${unit}` : ''}
${trend || ''}
`; } // 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 += ` ${d.name} ${C.fmt(d.value, d.value >= 10 ? 0 : 3)} `; }); return `
${rows} 国标限值
`; } return `
` + D.pollutants.map(d => { const ratio = d.value / d.limit, sk = statusKey(ratio); return `
${C.ringGauge({ value: d.value, limit: d.limit, unit: d.unit, name: d.name, en: d.en, p })}
${d.name}
${d.en} · 限值 ${C.fmt(d.limit, 2)}
${cnLevel[sk]} ${Math.round(ratio * 100)}%
`; }).join('') + `
`; } // 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 = `
` + segs.map(s => `
${s.label}房间${s.value}${pct(s.value, total)}%
` ).join('') + `
`; if (mode === 'bar') { let x = 0; const w = 100; const segbar = segs.map(s => { const wpc = s.value / total * w; const r = `
`; x += wpc; return r; }).join(''); return `
${segbar}
${D.kpis.compliance}%
总体房间达标率
${legend}`; } return `
${C.donut({ segments: segs, centerNum: D.kpis.compliance + '%', centerLabel: '达标率', p })}
${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 = `
工程污染概览
${D.project.name} · ${D.project.area}㎡ · ${D.project.type}
GB/T 18883GB 50325
${ICON.bell}
`; const side = `
污染物预测系统
INDOOR · AIR
${nav(0)}
陈工 · 环境工程师
★ 专业版
`; const kpis = `
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', `${ICON.up}本周 +3`)} ${kpiCard('房间达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `${ICON.up}${D.kpis.trend.compliance}%`)} ${kpiCard('当前超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', `${ICON.down}${Math.abs(D.kpis.trend.exceed)}`)} ${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', `${ICON.up}活跃`)}
`; const totalRooms = D.compliance.reduce((s, x) => s + x.value, 0); const colCard = `
各房间 · 甲醛预测浓度
柱状图按状态着色 · 红=超标 / 橙=临界 / 绿=达标,对照国标限值
单位 mg/m³
${C.columns({ items: D.rooms, limit: D.roomLimit, limit2: D.roomLimit2, unit: 'mg/m³', p, w: 760, h: 268 })}
`; const donutCard = `
项目达标率
${totalRooms} 间房
${complianceViz(opts.compliance || 'donut', p, totalRooms)}
`; const gaugeCard = `
最近一次预测 · 主卧
${D.project.updated} · 6 项污染物 vs 国标限值
${ICON.alert}甲醛 ${hcho.value} mg/m³ · 超出 GB/T 18883 限值 ${over}%,建议加强通风并核查人造板材料
${pollutantViz(opts.pollutant || 'ring', p)}
`; const radarCard = `
污染物雷达
主卧
虚线红环 = 国标限值(比值 1.0)
${C.radar({ axes: D.pollutants.map(d => ({ name: d.name, value: d.value, limit: d.limit })), p, size: 250 })}
`; const decayCard = `
甲醛衰减
随通风天数
${C.decayArea({ points: D.decay, limit: D.roomLimit, unit: 'mg/m³', p, w: 300, h: 176 })}
`; const tableRows = D.exceed.map(r => { const ratio = r.value / r.limit; return `
${r.room}
${r.project}
${r.pollutant} ${C.fmt(r.value, r.value >= 10 ? 0 : 3)} ${C.fmt(r.limit, 2)} ${cnLevel[r.level]} ${Math.round(ratio * 100)}%`; }).join(''); const tableCard = `
超标房间清单
${ICON.alert ? '' : ''}共 ${D.exceed.length} 条 · 按超标率排序
${tableRows}
房间 / 项目污染物预测浓度限值状态
`; const matCard = `
污染源识别 · 材料贡献
主卧甲醛 · 公式溯源排行
${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 })}
`; let body; if (warm) { const heroComp = `
${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 })}
在管 ${D.kpis.projects} 个项目 · ${totalRooms} 间房
${D.compliance[2].value} 间超标 · ${D.compliance[1].value} 间临界
最近更新 ${D.project.updated}
${kpiCard('在管项目', 'folder', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.projects, '个', '')} ${kpiCard('达标率', 'predict', 'var(--good-soft)', 'var(--good)', D.kpis.compliance, '%', `${ICON.up}${D.kpis.trend.compliance}`)} ${kpiCard('超标房间', 'alert', 'var(--bad-soft)', 'var(--bad)', D.kpis.exceedRooms, '间', '')} ${kpiCard('本周预测', 'flask', 'var(--accent-soft)', 'var(--accent-on)', D.kpis.weekPredictions, '次', '')}
`; body = heroComp + `
${colCard} ${gaugeCard.replace('sp5','sp4')} ${radarCard} ${decayCard.replace('sp3','sp4')} ${matCard} ${tableCard.replace('sp8','sp12')}
`; } else { body = kpis + `
${colCard}${donutCard} ${gaugeCard}${radarCard}${decayCard} ${tableCard}${matCard}
`; } return `
${C.defs(p)}${side}
${top}
${body}
`; }; })();