185 lines
12 KiB
JavaScript
185 lines
12 KiB
JavaScript
/* 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 `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" class="ch-ring">
|
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${p.track}" stroke-width="8"/>
|
|
${over
|
|
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round"/>`
|
|
: `<path d="${prog}" fill="none" stroke="${col}" stroke-width="8" stroke-linecap="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>`}
|
|
<text x="${cx}" y="${cy - 2}" text-anchor="middle" font-size="20" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(value, value >= 10 ? 0 : 3)}</text>
|
|
<text x="${cx}" y="${cy + 14}" text-anchor="middle" font-size="9" fill="${p.sub}">${unit}</text>
|
|
</svg>`;
|
|
}
|
|
|
|
/* ── 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 += `<path d="${arcPath(cx, cy, r, a + 0.012, a1 - 0.012)}" fill="none" stroke="${s.color}" stroke-width="22" stroke-linecap="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>`;
|
|
a = a1;
|
|
});
|
|
return `<svg viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
|
|
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${p.track}" stroke-width="22"/>
|
|
${paths}
|
|
<text x="${cx}" y="${cy - 4}" text-anchor="middle" font-size="40" font-weight="800" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${centerNum}</text>
|
|
<text x="${cx}" y="${cy + 20}" text-anchor="middle" font-size="13" fill="${p.sub}" letter-spacing="1">${centerLabel}</text>
|
|
</svg>`;
|
|
}
|
|
|
|
/* ── 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 += `<rect x="${(cx - bw / 2).toFixed(1)}" y="${by.toFixed(1)}" width="${bw}" height="${bh.toFixed(1)}" rx="4" fill="${col}"${p.glow ? ` filter="url(#chGlowSoft)"` : ''}/>
|
|
<text x="${cx.toFixed(1)}" y="${(by - 7).toFixed(1)}" text-anchor="middle" font-size="11" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(d.value, 3)}</text>`;
|
|
labels += `<text x="${cx.toFixed(1)}" y="${h - padB + 18}" text-anchor="middle" font-size="11" fill="${p.sub}">${d.name}</text>`;
|
|
});
|
|
// gridlines
|
|
let grid = '';
|
|
for (let g = 0; g <= 4; g++) {
|
|
const gy = padT + (ih / 4) * g;
|
|
grid += `<line x1="${padL}" y1="${gy.toFixed(1)}" x2="${w - padR}" y2="${gy.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>
|
|
<text x="${padL - 8}" y="${(gy + 3).toFixed(1)}" text-anchor="end" font-size="9" fill="${p.faint}" style="font-variant-numeric:tabular-nums">${fmt(max - (max / 4) * g, 2)}</text>`;
|
|
}
|
|
const ly = y(limit), ly2 = limit2 ? y(limit2) : null;
|
|
const limLine = `<line x1="${padL}" y1="${ly.toFixed(1)}" x2="${w - padR}" y2="${ly.toFixed(1)}" stroke="${p.bad}" stroke-width="1.5" stroke-dasharray="6 4"/>
|
|
<rect x="${w - padR - 150}" y="${(ly - 17).toFixed(1)}" width="150" height="15" rx="3" fill="${p.badSoft}"/>
|
|
<text x="${w - padR - 6}" y="${(ly - 6).toFixed(1)}" text-anchor="end" font-size="9.5" font-weight="600" fill="${p.bad}">GB/T 18883 限值 ${fmt(limit, 2)}</text>`;
|
|
const lim2Line = limit2 ? `<line x1="${padL}" y1="${ly2.toFixed(1)}" x2="${w - padR}" y2="${ly2.toFixed(1)}" stroke="${p.warn}" stroke-width="1.2" stroke-dasharray="3 4"/>
|
|
<text x="${w - padR - 6}" y="${(ly2 + 12).toFixed(1)}" text-anchor="end" font-size="9.5" font-weight="600" fill="${p.warn}">GB 50325-I 限值 ${fmt(limit2, 2)}</text>` : '';
|
|
return `<svg viewBox="0 0 ${w} ${h}" width="100%" preserveAspectRatio="xMidYMid meet">
|
|
${grid}${bars}${lim2Line}${limLine}${labels}</svg>`;
|
|
}
|
|
|
|
/* ── 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 += `<g transform="translate(0 ${yy})">
|
|
<text x="0" y="${rowH / 2 + 4}" font-size="12" 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="${d.color || p.accent}"${p.glow ? ` filter="url(#chGlowSoft)"` : ''}/>
|
|
<text x="${w}" y="${rowH / 2 + 4}" text-anchor="end" font-size="11" font-weight="700" fill="${p.ink}" style="font-variant-numeric:tabular-nums">${fmt(d.value, 3)}</text>
|
|
</g>`;
|
|
});
|
|
return `<svg viewBox="0 0 ${w} ${items.length * rowH}" width="100%">${rows}</svg>`;
|
|
}
|
|
|
|
/* ── 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 += `<polygon points="${pts}" fill="none" stroke="${g === 1 ? p.bad : p.grid}" stroke-width="${g === 1 ? 1.3 : 1}" ${g === 1 ? 'stroke-dasharray="5 4"' : ''}/>`;
|
|
});
|
|
let spokes = '', labels = '';
|
|
axes.forEach((a, i) => {
|
|
const [x, y] = pol(cx, cy, R, ang(i));
|
|
spokes += `<line x1="${cx}" y1="${cy}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>`;
|
|
const [lx, ly] = pol(cx, cy, R + 20, ang(i));
|
|
const anchor = Math.abs(lx - cx) < 8 ? 'middle' : lx > cx ? 'start' : 'end';
|
|
labels += `<text x="${lx.toFixed(1)}" y="${(ly + 4).toFixed(1)}" text-anchor="${anchor}" font-size="11" font-weight="600" fill="${p.sub}">${a.name}</text>`;
|
|
});
|
|
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 `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${p.accent}"/>`;
|
|
}).join('');
|
|
return `<svg viewBox="0 0 ${size} ${size}" width="100%">
|
|
${grid}${spokes}
|
|
<polygon points="${vpts}" fill="${p.accent}" fill-opacity="0.18" stroke="${p.accent}" stroke-width="2"${p.glow ? ` filter="url(#chGlow)"` : ''}/>
|
|
${dots}${labels}</svg>`;
|
|
}
|
|
|
|
/* ── 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 += `<line x1="${padL}" y1="${gy.toFixed(1)}" x2="${w - padR}" y2="${gy.toFixed(1)}" stroke="${p.grid}" stroke-width="1"/>`;
|
|
}
|
|
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 => `<circle cx="${X(d.day).toFixed(1)}" cy="${Y(d.v).toFixed(1)}" r="2.6" fill="${p.accent}"/>`).join('');
|
|
const xlab = points.filter((_, i) => i % 2 === 0).map(d => `<text x="${X(d.day).toFixed(1)}" y="${h - 8}" text-anchor="middle" font-size="9" fill="${p.faint}">${d.day}天</text>`).join('');
|
|
const ly = Y(limit);
|
|
return `<svg viewBox="0 0 ${w} ${h}" width="100%">
|
|
<defs><linearGradient id="${p.gid}_dk" x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0" stop-color="${p.accent}" stop-opacity="0.35"/>
|
|
<stop offset="1" stop-color="${p.accent}" stop-opacity="0.02"/></linearGradient></defs>
|
|
${grid}
|
|
<line x1="${padL}" y1="${ly.toFixed(1)}" x2="${w - padR}" y2="${ly.toFixed(1)}" stroke="${p.bad}" stroke-width="1.2" stroke-dasharray="5 4"/>
|
|
<text x="${w - padR}" y="${(ly - 5).toFixed(1)}" text-anchor="end" font-size="9" fill="${p.bad}">限值 ${fmt(limit, 2)}</text>
|
|
<path d="${area}" fill="url(#${p.gid}_dk)"/>
|
|
<path d="${line}" fill="none" stroke="${p.accent}" stroke-width="2.4" stroke-linejoin="round"${p.glow ? ` filter="url(#chGlow)"` : ''}/>
|
|
${dots}${xlab}</svg>`;
|
|
}
|
|
|
|
/* defs for optional glow filters — inject once per dashboard root */
|
|
function defs(p) {
|
|
if (!p.glow) return '';
|
|
return `<svg width="0" height="0" style="position:absolute"><defs>
|
|
<filter id="chGlow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
|
<filter id="chGlowSoft" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.2" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
|
</defs></svg>`;
|
|
}
|
|
|
|
window.CHARTS = { ringGauge, donut, columns, hbars, radar, decayArea, defs, status, fmt };
|
|
})();
|