/* 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 ` ${over ? `` : ``} ${fmt(value, value >= 10 ? 0 : 3)} ${unit} `; } /* ── 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 ` ${paths} ${centerNum} ${centerLabel} `; } /* ── 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 ` ${grid}${bars}${lim2Line}${limLine}${labels}`; } /* ── 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 `${rows}`; } /* ── 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 ` ${grid}${spokes} ${dots}${labels}`; } /* ── 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 ` ${grid} 限值 ${fmt(limit, 2)} ${dots}${xlab}`; } /* 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 }; })();