/* charts.js — hand-built SVG chart library for the pollution dashboard.
Every builder returns an SVG markup string. Colors come from a palette
object `p` so the same chart adapts to each theme.
window.CHARTS = { ... } */
(function () {
const TAU = Math.PI * 2;
const fmt = (n, d = 2) => Number(n).toFixed(d).replace(/\.?0+$/, m => m.includes('.') ? '' : m);
const pol = (cx, cy, r, a) => [cx + r * Math.cos(a), cy + r * Math.sin(a)];
// describe an SVG arc from angle a0 to a1 (radians), radius r, center cx,cy
function arcPath(cx, cy, r, a0, a1) {
const [x0, y0] = pol(cx, cy, r, a0);
const [x1, y1] = pol(cx, cy, r, a1);
const large = (a1 - a0) % TAU > Math.PI ? 1 : 0;
return `M${x0.toFixed(2)} ${y0.toFixed(2)} A${r} ${r} 0 ${large} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
}
const status = (ratio, p) => ratio >= 1 ? p.bad : ratio >= 0.85 ? p.warn : p.good;
/* ── ringGauge: circular progress vs the national-standard limit.
100% of the ring = the GB/T limit. Overshoot (>limit) paints the
full ring in the "bad" colour. Center shows the value. ── */
function ringGauge({ value, limit, unit, name, en, p, size = 104 }) {
const ratio = value / limit;
const col = status(ratio, p);
const r = size / 2 - 9, cx = size / 2, cy = size / 2, C = TAU * r;
const frac = Math.min(ratio, 1);
const start = -Math.PI / 2;
const prog = arcPath(cx, cy, r, start, start + frac * TAU - 0.0001);
const over = ratio > 1;
return ``;
}
/* ── donut: multi-segment compliance pie with big centre stat ── */
function donut({ segments, centerNum, centerLabel, p, size = 210 }) {
const total = segments.reduce((s, x) => s + x.value, 0);
const r = size / 2 - 16, cx = size / 2, cy = size / 2;
let a = -Math.PI / 2, paths = '';
segments.forEach(s => {
const a1 = a + (s.value / total) * TAU;
paths += ``;
a = a1;
});
return ``;
}
/* ── columns: vertical bar chart of per-room concentration, bars coloured
by status, with two dashed national-standard limit lines. The hero
"clearly shows exceedance" chart. ── */
function columns({ items, limit, limit2, unit, p, w = 720, h = 300 }) {
const padL = 46, padR = 18, padT = 26, padB = 46;
const iw = w - padL - padR, ih = h - padT - padB;
const max = Math.max(limit, limit2 || 0, ...items.map(d => d.value)) * 1.22;
const y = v => padT + ih - (v / max) * ih;
const bw = Math.min(54, (iw / items.length) * 0.56);
const step = iw / items.length;
let bars = '', labels = '';
items.forEach((d, i) => {
const cx = padL + step * i + step / 2;
const ratio = d.value / limit;
const col = status(ratio, p);
const by = y(d.value), bh = padT + ih - by;
bars += `
${fmt(d.value, 3)}`;
labels += `${d.name}`;
});
// gridlines
let grid = '';
for (let g = 0; g <= 4; g++) {
const gy = padT + (ih / 4) * g;
grid += `
${fmt(max - (max / 4) * g, 2)}`;
}
const ly = y(limit), ly2 = limit2 ? y(limit2) : null;
const limLine = `
GB/T 18883 限值 ${fmt(limit, 2)}`;
const lim2Line = limit2 ? `
GB 50325-I 限值 ${fmt(limit2, 2)}` : '';
return ``;
}
/* ── hbars: horizontal ranking (material pollution contribution) ── */
function hbars({ items, p, w = 360, rowH = 38 }) {
const max = Math.max(...items.map(d => d.value));
const labW = 0, barX = 150, barW = w - barX - 56;
let rows = '';
items.forEach((d, i) => {
const yy = i * rowH;
const bw = Math.max(4, (d.value / max) * barW);
rows += `
${d.name}
${fmt(d.value, 3)}
`;
});
return ``;
}
/* ── radar: 6-axis pollutant chart. Ring at ratio 1.0 = the limit. ── */
function radar({ axes, p, size = 280 }) {
const cx = size / 2, cy = size / 2 + 4, R = size / 2 - 46;
const n = axes.length;
const ang = i => -Math.PI / 2 + (i / n) * TAU;
// scale: value/limit, ring max 1.6
const RMAX = 1.6;
const rr = v => (Math.min(v, RMAX) / RMAX) * R;
let grid = '';
[0.5, 1.0, 1.5].forEach(g => {
const pts = axes.map((_, i) => pol(cx, cy, rr(g), ang(i)).map(x => x.toFixed(1)).join(',')).join(' ');
grid += ``;
});
let spokes = '', labels = '';
axes.forEach((a, i) => {
const [x, y] = pol(cx, cy, R, ang(i));
spokes += ``;
const [lx, ly] = pol(cx, cy, R + 20, ang(i));
const anchor = Math.abs(lx - cx) < 8 ? 'middle' : lx > cx ? 'start' : 'end';
labels += `${a.name}`;
});
const vpts = axes.map((a, i) => pol(cx, cy, rr(a.value / a.limit), ang(i)).map(x => x.toFixed(1)).join(',')).join(' ');
const dots = axes.map((a, i) => {
const [x, y] = pol(cx, cy, rr(a.value / a.limit), ang(i));
return ``;
}).join('');
return ``;
}
/* ── decayArea: concentration decay over ventilation days ── */
function decayArea({ points, limit, unit, p, w = 360, h = 200 }) {
const padL = 38, padR = 14, padT = 16, padB = 28;
const iw = w - padL - padR, ih = h - padT - padB;
const xs = points.map(d => d.day), maxX = Math.max(...xs);
const maxY = Math.max(limit, ...points.map(d => d.v)) * 1.15;
const X = d => padL + (d / maxX) * iw;
const Y = v => padT + ih - (v / maxY) * ih;
let grid = '';
for (let g = 0; g <= 3; g++) {
const gy = padT + (ih / 3) * g;
grid += ``;
}
const line = points.map((d, i) => `${i ? 'L' : 'M'}${X(d.day).toFixed(1)} ${Y(d.v).toFixed(1)}`).join(' ');
const area = `${line} L${X(maxX).toFixed(1)} ${padT + ih} L${padL} ${padT + ih} Z`;
const dots = points.map(d => ``).join('');
const xlab = points.filter((_, i) => i % 2 === 0).map(d => `${d.day}天`).join('');
const ly = Y(limit);
return ``;
}
/* defs for optional glow filters — inject once per dashboard root */
function defs(p) {
if (!p.glow) return '';
return ``;
}
window.CHARTS = { ringGauge, donut, columns, hbars, radar, decayArea, defs, status, fmt };
})();