airpredict/apps/web/public/dashboard/charts.js

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 };
})();