/* 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 ``;
}
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}
${ICON.search}搜索项目 / 房间 / 材料
GB/T 18883GB 50325
${ICON.bell}
`;
const side = `
${warm ? ICON.leaf : ICON.bldg}
${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 = `
${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 ``;
};
})();