|
@@ -0,0 +1,268 @@
|
|
|
|
|
+/* 观澜 图元 —— 从经典页 CHART_JS 搬出的 7 个 SVG 图元, 文案全部走语言包。
|
|
|
|
|
+ * 用法: const CH = window.__charts({ t, fmt, esc, dv, EN }); (app.js 里调用, 同一份 ctx)
|
|
|
|
|
+ * 输入形状与经典 /api/fleet 一致 (faults.quad / faults.monthly / control.energy / rel.表 / faults.pareto_n)。
|
|
|
|
|
+ * 设计: 海蓝 = 主, 红 = 需注意, 灰 = 外部/本底; 无动画。
|
|
|
|
|
+ */
|
|
|
|
|
+window.__charts = function (ctx) {
|
|
|
|
|
+ 'use strict';
|
|
|
|
|
+ const { t, fmt, esc, dv, EN } = ctx;
|
|
|
|
|
+ // ★SVG 里不能用 dv(): 它返回 HTML <span>, SVG 不认这个元素, 原文标注会静默失效 (英文点检实逮)。
|
|
|
|
|
+ // 图元里的数据字符串走**纯文本**通道, 由 SVG 元素自身带 class="verbatim" 承担标注与计数。
|
|
|
|
|
+ const CJK = /[㐀-鿿]/;
|
|
|
|
|
+ const isV = (x) => EN && CJK.test(String(x == null ? '' : x));
|
|
|
|
|
+ const plain = (x) => { x = ctx.displayText ? ctx.displayText(x) : (x == null ? '' : String(x)); if (isV(x) && ctx.verb) ctx.verb(); return x; };
|
|
|
|
|
+ const ttl = (x) => { const label = plain(x); return `<title${isV(label) ? ' class="verbatim"' : ''}>${esc(label)}</title>`; };
|
|
|
|
|
+ const svg = (W, H, body) => `<svg class="dt" viewBox="0 0 ${W} ${H}" style="width:${W}px;max-width:100%;height:auto" role="img">${body}</svg>`;
|
|
|
|
|
+ const txt = (x, y, s, o = {}) => `<text x="${(+x).toFixed(1)}" y="${(+y).toFixed(1)}" font-size="${o.fs || 9}" fill="${o.fill || 'var(--muted)'}"${o.anchor ? ` text-anchor="${o.anchor}"` : ''}${o.op ? ` opacity="${o.op}"` : ''}${o.w ? ` font-weight="${o.w}"` : ''}${o.tr ? ` transform="${o.tr}"` : ''}${o.cls ? ` class="${o.cls}"` : ''}>${s}</text>`;
|
|
|
|
|
+ // 数据字符串进 SVG 文本: 转义 + (英文模式下含中文则) 标注
|
|
|
|
|
+ const dtxt = (x, y, raw, o = {}) => { const p = plain(raw); return txt(x, y, esc(p), isV(p) ? Object.assign({}, o, { cls: 'verbatim' }) : o); };
|
|
|
|
|
+ const LOSS = { '限电·绑定': 'loss.curtail_bound', '限电命令·未绑定': 'loss.curtail_unbound', '停机(调度令)': 'loss.dispatch', '停机': 'loss.stop', '正常发电': 'loss.normal', '低风待机': 'loss.lowwind' };
|
|
|
|
|
+ const lossName = (k) => LOSS[k] ? t(LOSS[k]) : plain(k);
|
|
|
|
|
+
|
|
|
|
|
+ // 故障四象限: 条数 × 单次中位时长 (对数); 分界 = 条数中位 × 600s (一个 SCADA 采样格, 物理刻度)
|
|
|
|
|
+ function quadChart(items) {
|
|
|
|
|
+ const it = (items || []).filter(x => x && x.n > 0 && x.med > 0);
|
|
|
|
|
+ if (it.length < 3) return `<p class="small muted">${t('ch.quad.few')}</p>`;
|
|
|
|
|
+ const W = 720, H = 330, ML = 58, MR = 16, MB = 44, MT = 16;
|
|
|
|
|
+ const lx = x => Math.log10(x), xs = it.map(d => lx(d.n)), ys = it.map(d => lx(d.med));
|
|
|
|
|
+ const x0 = Math.min(...xs) - .15, x1 = Math.max(...xs) + .15, y0 = Math.min(...ys) - .15, y1 = Math.max(...ys) + .15;
|
|
|
|
|
+ const PX = v => ML + (lx(v) - x0) / (x1 - x0) * (W - ML - MR), PY = v => H - MB - (lx(v) - y0) / (y1 - y0) * (H - MT - MB);
|
|
|
|
|
+ const srt = it.map(d => d.n).sort((a, b) => a - b), nmid = srt[Math.floor(srt.length / 2)];
|
|
|
|
|
+ const cx = PX(nmid), cy = PY(600), mt = Math.max(...it.map(d => d.nt));
|
|
|
|
|
+ const pts = it.map(d => { const r = 3 + Math.sqrt(d.nt / mt) * 7, hot = d.n >= nmid && d.med >= 600, c = hot ? 'var(--critical)' : 'var(--accent)';
|
|
|
|
|
+ return `<circle cx="${PX(d.n).toFixed(1)}" cy="${PY(d.med).toFixed(1)}" r="${r.toFixed(1)}" fill="${c}" fill-opacity=".5" stroke="${c}" stroke-width="1">${ttl(`${d.code} ${plain(d.k)}\n` + t('ch.quad.tip', { n: d.n, med: d.med, h: d.h, nt: d.nt }))}</circle>`; }).join('');
|
|
|
|
|
+ const lab = it.filter(d => d.n >= nmid || d.med >= 600).slice(0, 8).map(d => txt(PX(d.n) + 9, PY(d.med) + 3, esc(d.code))).join('');
|
|
|
|
|
+ const qn = (x, y, a, b) => txt(x, y, a, { fs: 10, op: .85 }) + txt(x, y + 12, b, { fs: 8.5, op: .6 });
|
|
|
|
|
+ const tick = v => v >= 3600 ? (v / 3600) + 'h' : (v >= 60 ? (v / 60) + 'min' : v + 's');
|
|
|
|
|
+ const yt = [10, 60, 600, 3600, 36000].filter(v => lx(v) >= y0 && lx(v) <= y1).map(v => `<line x1="${ML}" y1="${PY(v).toFixed(1)}" x2="${W - MR}" y2="${PY(v).toFixed(1)}" stroke="var(--line)" stroke-opacity=".4"/>` + txt(ML - 6, PY(v) + 3, tick(v), { anchor: 'end' })).join('');
|
|
|
|
|
+ const xt = [1, 10, 100, 1000, 10000].filter(v => lx(v) >= x0 && lx(v) <= x1).map(v => txt(PX(v), H - MB + 14, v, { anchor: 'middle' })).join('');
|
|
|
|
|
+ const mid = MT + (H - MB - MT) / 2;
|
|
|
|
|
+ return svg(W, H, `${yt}${xt}
|
|
|
|
|
+ <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H - MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".5"/>
|
|
|
|
|
+ <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W - MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".5"/>
|
|
|
|
|
+ ${qn(ML + 6, MT + 12, t('ch.quad.q1'), t('ch.quad.q1s'))}${qn(W - MR - 130, MT + 12, t('ch.quad.q2'), t('ch.quad.q2s'))}
|
|
|
|
|
+ ${qn(ML + 6, H - MB - 18, t('ch.quad.q3'), t('ch.quad.q3s'))}${qn(W - MR - 130, H - MB - 18, t('ch.quad.q4'), t('ch.quad.q4s'))}
|
|
|
|
|
+ ${pts}${lab}
|
|
|
|
|
+ ${txt(ML + (W - MR - ML) / 2, H - 6, t('ch.quad.xlab'), { fs: 9.5, anchor: 'middle' })}
|
|
|
|
|
+ ${txt(14, mid, t('ch.quad.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 14 ${mid.toFixed(1)})` })}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 月度故障率: 柱 = 日均 (可比); 残月灰虚线柱; 标总条数 (原始事实)
|
|
|
|
|
+ function rateChart(mo) {
|
|
|
|
|
+ const ms = mo.months || [], vs = mo.vals || [], cov = mo.cov || ms.map(() => 1), pd = mo.per_day || [];
|
|
|
|
|
+ const W = 720, H = 250, ML = 48, MR = 14, MB = 52, MT = 18, n = ms.length || 1;
|
|
|
|
|
+ const mx = Math.max(...pd.filter(x => x != null), 1), bw = (W - ML - MR) / n;
|
|
|
|
|
+ let bars = '', lab = '';
|
|
|
|
|
+ ms.forEach((m, i) => { const v = pd[i], part = cov[i] < .5; if (v == null) return;
|
|
|
|
|
+ const hh = v / mx * (H - MT - MB), bx = ML + i * bw + bw * .16, by = H - MB - hh, cxm = bx + bw * .34;
|
|
|
|
|
+ bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * .68).toFixed(1)}" height="${hh.toFixed(1)}" rx="2" fill="${part ? 'var(--muted)' : 'var(--accent)'}" fill-opacity="${part ? .32 : .8}" stroke="${part ? 'var(--warning)' : 'none'}" stroke-dasharray="${part ? '3 2' : ''}"><title>${t('ch.rate.tip', { m, v, n: vs[i], cov: (cov[i] * 100).toFixed(0) })}</title></rect>`;
|
|
|
|
|
+ lab += txt(cxm, by - 4, v, { anchor: 'middle' }) + txt(cxm, H - MB + 13, t('ch.rate.month', { m: m.slice(5) }), { anchor: 'middle' })
|
|
|
|
|
+ + txt(cxm, H - MB + 25, t('ch.rate.n', { n: vs[i] }), { fs: 8, op: .65, anchor: 'middle' })
|
|
|
|
|
+ + (part ? txt(cxm, H - MB + 37, t('ch.rate.days', { d: Math.round(cov[i] * 30) }), { fs: 8, fill: 'var(--warning)', anchor: 'middle' }) : ''); });
|
|
|
|
|
+ const yt = [0, .5, 1].map(f => txt(ML - 6, H - MB - f * (H - MT - MB) + 3, Math.round(mx * f), { anchor: 'end' })).join('');
|
|
|
|
|
+ const mid = MT + (H - MB - MT) / 2;
|
|
|
|
|
+ return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>
|
|
|
|
|
+ ${bars}${lab}${yt}${txt(14, mid, t('ch.rate.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 14 ${mid.toFixed(1)})` })}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 能量瀑布: 理论可发 → 逐段扣损 (按损失降序) → 实际上网
|
|
|
|
|
+ function waterfall(e) {
|
|
|
|
|
+ if (!e || !e.theo) return '';
|
|
|
|
|
+ const W = 720, H = 300, ML = 8, MR = 8, MT = 30, MB = 64, items = (e.items || []).filter(x => x.loss > 0);
|
|
|
|
|
+ const cols = items.length + 2, cw = (W - ML - MR) / cols, sc = (H - MT - MB) / e.theo;
|
|
|
|
|
+ let run = e.theo, h = '', i = 0;
|
|
|
|
|
+ const bar = (idx, y, hh, fill, op) => `<rect x="${(ML + idx * cw + cw * .14).toFixed(1)}" y="${y.toFixed(1)}" width="${(cw * .72).toFixed(1)}" height="${Math.max(hh, 1.2).toFixed(1)}" rx="2" fill="${fill}" fill-opacity="${op}"/>`;
|
|
|
|
|
+ const lab = (idx, y, nm, v, c) => txt(ML + idx * cw + cw * .5, y - 6, v, { fs: 10, fill: c || 'var(--ink)', anchor: 'middle', w: 600 }) + txt(ML + idx * cw + cw * .5, H - MB + 14, nm, { fs: 8.5, anchor: 'middle' });
|
|
|
|
|
+ h += bar(0, H - MB - e.theo * sc, e.theo * sc, 'var(--accent)', .45) + lab(0, H - MB - e.theo * sc, t('ch.wf.theo'), fmt(e.theo));
|
|
|
|
|
+ items.forEach(x => { i++; const top = H - MB - run * sc, hh = x.loss * sc; run -= x.loss; const nm = lossName(x.k);
|
|
|
|
|
+ h += bar(i, top, hh, 'var(--critical)', .55) + `<line x1="${(ML + (i - 1) * cw + cw * .86).toFixed(1)}" y1="${top.toFixed(1)}" x2="${(ML + i * cw + cw * .14).toFixed(1)}" y2="${top.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="2 2" stroke-opacity=".6"/>`
|
|
|
|
|
+ + lab(i, top, esc(nm.length > 12 ? nm.slice(0, 11) + '…' : nm), '−' + fmt(x.loss), 'var(--critical)')
|
|
|
|
|
+ + ttl(t('ch.wf.tip', { nm, loss: fmt(x.loss), pct: (x.loss / e.theo * 100).toFixed(1), h: fmt(x.h) })); });
|
|
|
|
|
+ h += bar(cols - 1, H - MB - e.act * sc, e.act * sc, 'var(--good)', .6) + lab(cols - 1, H - MB - e.act * sc, t('ch.wf.act'), fmt(e.act), 'var(--good)');
|
|
|
|
|
+ return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/>${txt(ML, 14, 'MWh', { fs: 9.5 })}${h}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 可靠性 × 可维修性象限: x = MTBF (对数), y = MDT, 面积 = 累计停机
|
|
|
|
|
+ function ramChart(tb, partName) {
|
|
|
|
|
+ const it = (tb || []).filter(x => x.MTBF_h && x.MDT_h);
|
|
|
|
|
+ if (it.length < 3) return `<p class="small muted">${t('ch.ram.few')}</p>`;
|
|
|
|
|
+ const W = 720, H = 340, ML = 56, MR = 112, MB = 48, MT = 16;
|
|
|
|
|
+ const lg = v => Math.log10(v), xs = it.map(d => lg(d.MTBF_h)), ys = it.map(d => d.MDT_h);
|
|
|
|
|
+ const x0 = Math.min(...xs) - .08, x1 = Math.max(...xs) + .08, y0 = 0, y1 = Math.max(...ys) * 1.12;
|
|
|
|
|
+ const PX = v => ML + (lg(v) - x0) / (x1 - x0) * (W - ML - MR), PY = v => H - MB - (v - y0) / (y1 - y0) * (H - MT - MB);
|
|
|
|
|
+ const md = a => { const z = a.slice().sort((p, q) => p - q); return z[Math.floor(z.length / 2)]; };
|
|
|
|
|
+ const mMT = md(it.map(d => d.MTBF_h)), mMD = md(ys), cx = PX(mMT), cy = PY(mMD), mh = Math.max(...it.map(d => d.停机时长h));
|
|
|
|
|
+ const nm = d => plain(partName ? partName(d.部件) : d.部件);
|
|
|
|
|
+ const pts = it.map(d => { const r = 4 + Math.sqrt(d.停机时长h / mh) * 13, ext = d.外部, hot = d.MTBF_h < mMT && d.MDT_h >= mMD, c = ext ? 'var(--muted)' : (hot ? 'var(--critical)' : 'var(--accent)');
|
|
|
|
|
+ return `<circle cx="${PX(d.MTBF_h).toFixed(1)}" cy="${PY(d.MDT_h).toFixed(1)}" r="${r.toFixed(1)}" fill="${c}" fill-opacity="${ext ? .3 : .42}" stroke="${c}" stroke-width="1.2" stroke-dasharray="${ext ? '3 2' : ''}">${ttl(t('ch.ram.tip', { p: nm(d), mtbf: d.MTBF_h, mdt: d.MDT_h, h: d.停机时长h, n: d.事件数, u: d.影响台数 }) + (ext ? t('ch.ram.tip_ext') : ''))}</circle>`
|
|
|
|
|
+ + dtxt(PX(d.MTBF_h) + r + 4, PY(d.MDT_h) + 3.5, plain(nm(d)).slice(0, EN ? 14 : 7), { fs: 9.5, fill: ext ? 'var(--muted)' : 'currentColor' }); }).join('');
|
|
|
|
|
+ const qn = (x, y, a, s1, s2) => txt(x, y, s1, { fs: 10, anchor: a, op: .9 }) + txt(x, y + 12, s2, { fs: 8.5, anchor: a, op: .62 });
|
|
|
|
|
+ const xt = [1000, 2000, 5000, 10000, 20000].filter(v => lg(v) >= x0 && lg(v) <= x1).map(v => `<line x1="${PX(v).toFixed(1)}" y1="${MT}" x2="${PX(v).toFixed(1)}" y2="${H - MB}" stroke="var(--line)" stroke-opacity=".3"/>` + txt(PX(v), H - MB + 14, v >= 1000 ? (v / 1000) + 'k' : v, { anchor: 'middle' })).join('');
|
|
|
|
|
+ const yt = [0, 5, 10, 15, 20].filter(v => v <= y1).map(v => txt(ML - 6, PY(v) + 3, v, { anchor: 'end' })).join('');
|
|
|
|
|
+ const mid = MT + (H - MB - MT) / 2;
|
|
|
|
|
+ return svg(W, H, `${xt}${yt}<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>
|
|
|
|
|
+ <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H - MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".45"/>
|
|
|
|
|
+ <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W - MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".45"/>
|
|
|
|
|
+ ${qn(ML + 6, MT + 12, 'start', t('ch.ram.q1'), t('ch.ram.q1s'))}${qn(W - MR - 6, MT + 12, 'end', t('ch.ram.q2'), t('ch.ram.q2s'))}
|
|
|
|
|
+ ${qn(ML + 6, H - MB - 16, 'start', t('ch.ram.q3'), t('ch.ram.q3s'))}${qn(W - MR - 6, H - MB - 16, 'end', t('ch.ram.q4'), t('ch.ram.q4s'))}
|
|
|
|
|
+ ${pts}${txt(ML + (W - MR - ML) / 2, H - 8, t('ch.ram.xlab'), { fs: 9.5, anchor: 'middle' })}
|
|
|
|
|
+ ${txt(13, mid, t('ch.ram.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 13 ${mid.toFixed(1)})` })}
|
|
|
|
|
+ <circle cx="${W - MR + 16}" cy="${MT + 40}" r="5" fill="var(--accent)" fill-opacity=".42" stroke="var(--accent)"/>${txt(W - MR + 26, MT + 44, t('ch.ram.equip'))}
|
|
|
|
|
+ <circle cx="${W - MR + 16}" cy="${MT + 58}" r="5" fill="var(--muted)" fill-opacity=".3" stroke="var(--muted)" stroke-dasharray="3 2"/>${txt(W - MR + 26, MT + 62, t('ch.ram.ext'))}
|
|
|
|
|
+ ${txt(W - MR + 6, MT + 84, t('ch.ram.area'), { fs: 8.5 })}${txt(W - MR + 6, MT + 98, t('ch.ram.median'), { fs: 8.5 })}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 归因覆盖条: 已归因 / 未归类 / 无记录
|
|
|
|
|
+ function attribBar(tb, tot) {
|
|
|
|
|
+ const nore = (tb || []).find(x => x.部件 === '无记录停机'), unk = nore ? nore.停机时长h : 0;
|
|
|
|
|
+ const un2 = (tb || []).filter(x => x.部件 === '未归类').reduce((a, b) => a + b.停机时长h, 0), ok = tot - unk - un2, W = 720, H = 54;
|
|
|
|
|
+ const seg = (x, w, c, op) => `<rect x="${x.toFixed(1)}" y="14" width="${Math.max(w, 1).toFixed(1)}" height="22" rx="3" fill="${c}" fill-opacity="${op}"/>`;
|
|
|
|
|
+ const w1 = ok / tot * W, w2 = un2 / tot * W, w3 = unk / tot * W;
|
|
|
|
|
+ return svg(W, H, `${seg(0, w1, 'var(--accent)', .55)}${seg(w1, w2, 'var(--warning)', .5)}${seg(w1 + w2, w3, 'var(--critical)', .5)}
|
|
|
|
|
+ ${txt(4, 10, t('ch.attrib.ok', { h: Math.round(ok), p: (ok / tot * 100).toFixed(0) }))}
|
|
|
|
|
+ ${txt(w1 + w2 + w3 - 2, 10, t('ch.attrib.none', { h: Math.round(unk), p: (unk / tot * 100).toFixed(0) }), { fill: 'var(--critical)', anchor: 'end' })}
|
|
|
|
|
+ ${txt(w1 + w2 / 2, 50, t('ch.attrib.uncls', { h: Math.round(un2) }), { fill: 'var(--warning)', anchor: 'middle' })}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 帕累托: 降序条 + 累计折线 + 80% 线
|
|
|
|
|
+ function paretoChart(items, unit, note) {
|
|
|
|
|
+ const it = (items || []).filter(x => x && x.v > 0);
|
|
|
|
|
+ if (!it.length) return `<p class="small muted">${t('ch.pareto.none')}</p>`;
|
|
|
|
|
+ const W = 520, H = 236, ML = 46, MR = 40, MB = 40, MT = 14, n = it.length, tot = it.reduce((a, x) => a + x.v, 0), mx = Math.max(...it.map(x => x.v)), bw = (W - ML - MR) / n;
|
|
|
|
|
+ const short = k => { k = plain(k); const m = String(k).match(/^([0-9]+)/); return m ? m[1] : String(k).slice(0, 4); };
|
|
|
|
|
+ let cum = 0, bars = '', line = [], dots = '', k80 = null;
|
|
|
|
|
+ it.forEach((x, i) => { const hh = x.v / mx * (H - MT - MB), bx = ML + i * bw + bw * .14, by = H - MB - hh;
|
|
|
|
|
+ bars += `<rect x="${bx.toFixed(1)}" y="${by.toFixed(1)}" width="${(bw * .72).toFixed(1)}" height="${hh.toFixed(1)}" rx="2" fill="var(--accent)" fill-opacity="${i < 3 ? .85 : .45}">${ttl(`${plain(x.k)}: ${fmt(x.v)}${unit}`)}</rect>`;
|
|
|
|
|
+ cum += x.v; const pct = cum / tot; if (k80 === null && pct >= .8) k80 = i + 1;
|
|
|
|
|
+ const px = ML + i * bw + bw / 2, py = MT + (1 - pct) * (H - MT - MB); line.push(`${px.toFixed(1)},${py.toFixed(1)}`);
|
|
|
|
|
+ dots += `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="2.4" fill="var(--warning)">${ttl(t('ch.pareto.cumtip', { k: plain(x.k), p: (pct * 100).toFixed(0) }))}</circle>`; });
|
|
|
|
|
+ const y80 = MT + .2 * (H - MT - MB);
|
|
|
|
|
+ const xt = it.map((x, i) => dtxt(ML + i * bw + bw / 2, H - MB + 13, short(x.k), { anchor: 'middle' })).join('');
|
|
|
|
|
+ const yt = [0, .5, 1].map(f => txt(ML - 5, H - MB - f * (H - MT - MB) + 3, fmt(Math.round(mx * f)), { anchor: 'end' })).join('') + [0, .5, 1].map(f => txt(W - MR + 5, MT + (1 - f) * (H - MT - MB) + 3, Math.round(f * 100) + '%', { fill: 'var(--warning)' })).join('');
|
|
|
|
|
+ const top3 = it.slice(0, 3).map(x => `<b>${isV(short(x.k)) ? `<span class="verbatim">${esc(plain(short(x.k)))}</span>` : esc(short(x.k))}</b> ${dv(plain(x.k).replace(/^[0-9]+ */, '').slice(0, 18))}`).join(' · ');
|
|
|
|
|
+ return svg(W, H, `<line x1="${ML}" y1="${H - MB}" x2="${W - MR}" y2="${H - MB}" stroke="var(--line)"/><line x1="${ML}" y1="${MT}" x2="${ML}" y2="${H - MB}" stroke="var(--line)"/>${bars}
|
|
|
|
|
+ <line x1="${ML}" y1="${y80.toFixed(1)}" x2="${W - MR}" y2="${y80.toFixed(1)}" stroke="var(--warning)" stroke-dasharray="4 3" stroke-opacity=".7"/>
|
|
|
|
|
+ ${txt(W - MR - 3, y80 - 4, t('ch.pareto.cum80'), { fill: 'var(--warning)', anchor: 'end' })}
|
|
|
|
|
+ <polyline points="${line.join(' ')}" fill="none" stroke="var(--warning)" stroke-width="1.5"/>${dots}${xt}${yt}
|
|
|
|
|
+ ${txt(ML + (W - MR - ML) / 2, H - 3, t('ch.pareto.xlab'), { fs: 8.5, anchor: 'middle' })}`)
|
|
|
|
|
+ + `<p class="small muted" style="margin-top:5px;line-height:1.6">${k80 ? `<b>${t('ch.pareto.key', { k: k80 })}</b> (${t('ch.pareto.total', { n, tot: fmt(Math.round(tot)), unit })})` : t('ch.pareto.total', { n, tot: fmt(Math.round(tot)), unit })}<br>${t('ch.pareto.top3')}${top3}${note ? '<br>' + note : ''}</p>`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 损失责任分账: 交易与调度侧 / 设备侧 / 运行侧
|
|
|
|
|
+ function blameBar(e) {
|
|
|
|
|
+ const cat = k => (k.indexOf('限电') >= 0 || k.indexOf('调度') >= 0) ? 0 : (k === '停机' ? 1 : 2);
|
|
|
|
|
+ const NM = [t('ch.blame.n1'), t('ch.blame.n2'), t('ch.blame.n3')], NOTE = [t('ch.blame.d1'), t('ch.blame.d2'), t('ch.blame.d3')], CL = ['var(--accent)', 'var(--critical)', 'var(--muted)'];
|
|
|
|
|
+ const g = [0, 0, 0]; (e.items || []).forEach(x => { g[cat(x.k)] += x.loss; });
|
|
|
|
|
+ const tot = g.reduce((a, b) => a + b, 0) || 1, W = 500, H = 44; let x = 0, seg = '';
|
|
|
|
|
+ g.forEach((v, i) => { const w = v / tot * W;
|
|
|
|
|
+ seg += `<rect x="${x.toFixed(1)}" y="12" width="${Math.max(w, 1).toFixed(1)}" height="26" rx="3" fill="${CL[i]}" fill-opacity=".55"><title>${NM[i]}: ${fmt(Math.round(v))} MWh</title></rect>` + (w > 46 ? txt(x + w / 2, 29, (v / tot * 100).toFixed(0) + '%', { fs: 10.5, fill: 'currentColor', anchor: 'middle', w: 600 }) : ''); x += w; });
|
|
|
|
|
+ const leg = g.map((v, i) => `<div style="display:flex;align-items:baseline;gap:7px;margin-top:5px"><i style="flex:none;width:9px;height:9px;border-radius:2px;background:${CL[i]};opacity:.55"></i><span style="font-weight:600">${NM[i]}</span><span class="num">${fmt(Math.round(v))} MWh</span><span class="small muted">${NOTE[i]}</span></div>`).join('');
|
|
|
|
|
+ return svg(W, H, `${txt(0, 8, t('ch.blame.title', { tot: fmt(Math.round(tot)) }))}${seg}`) + leg;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ // 多线月度图 (季节性): 至多 4 条序列, 同一 y 轴; 断点不连线
|
|
|
|
|
+ function multiline(f) {
|
|
|
|
|
+ const xs = f.months || f.x || [], n = xs.length; if (!n) return '';
|
|
|
|
|
+ const W = 620, H = 250, ML = 52, MB = 34, cols = ['var(--accent)', '#D48A1E', '#2E8B6A', '#7C5295'];
|
|
|
|
|
+ const all = []; (f.series || []).forEach(s => s.vals.forEach(v => { if (v != null && v === v) all.push(v); })); (f.thresholds || []).forEach(th => { if (th && th.v != null) all.push(th.v); });
|
|
|
|
|
+ if (!all.length) return '';
|
|
|
|
|
+ let lo = f.zero === false ? Math.min(...all) : Math.min(0, ...all), hi = Math.max(...all); if (lo === hi) { lo -= 1; hi += 1; } const pad = (hi - lo) * .08; hi += pad; if (f.zero === false) lo -= pad;
|
|
|
|
|
+ const X = i => ML + i / Math.max(n - 1, 1) * (W - ML - 12), Y = v => (H - MB) - (v - lo) / (hi - lo) * (H - MB - 14);
|
|
|
|
|
+ const line = (vals, c) => { let segs = [], cur = []; vals.forEach((v, i) => { if (v == null || v !== v) { if (cur.length) segs.push(cur); cur = []; } else cur.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); }); if (cur.length) segs.push(cur);
|
|
|
|
|
+ return segs.map(sg => `<polyline points="${sg.join(' ')}" fill="none" stroke="${c}" stroke-width="1.8"/>`).join(''); };
|
|
|
|
|
+ let body = (f.series || []).map((s, k) => line(s.vals, cols[k % 4])).join('');
|
|
|
|
|
+ const step = Math.max(1, Math.ceil(n / 8)); let xt = '';
|
|
|
|
|
+ for (let i = 0; i < n; i += step) xt += txt(X(i), H - 10, String(xs[i]).includes('-') ? String(xs[i]).slice(2, 7) : String(xs[i]), { fs: 10, anchor: 'middle' });
|
|
|
|
|
+ let yt = ''; for (let k = 0; k < 5; k++) { const v = lo + (hi - lo) * k / 4; yt += txt(ML - 6, Y(v) + 3, v.toFixed(Math.abs(hi) < 10 ? 1 : 0), { fs: 10, anchor: 'end' }) + `<line x1="${ML}" x2="${W - 12}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" stroke="var(--line)"/>`; }
|
|
|
|
|
+ (f.thresholds || []).forEach(th => { if (!th || th.v == null) return; body += `<line x1="${ML}" x2="${W - 12}" y1="${Y(th.v).toFixed(1)}" y2="${Y(th.v).toFixed(1)}" stroke="var(--muted)" stroke-dasharray="5 4"/>` + (th.label ? dtxt(ML + 6, Y(th.v) - 4, th.label, { fs: 10 }) : ''); });
|
|
|
|
|
+ if (f.xlab) body += dtxt((ML + W) / 2, H - 1, f.xlab, { fs: 10, anchor: 'middle' });
|
|
|
|
|
+ const leg = `<div class="legend" style="margin-top:6px;flex-wrap:wrap">` + (f.series || []).map((s, k) => `<span><i style="background:${cols[k % 4]}"></i>${isV(s.name) ? `<span class="verbatim">${esc(plain(s.name))}</span>` : esc(s.name)}</span>`).join('') + `</div>`;
|
|
|
|
|
+ return svg(W, H, `${yt}${body}${xt}`) + leg;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ // 封顶参数落位: 两轴 (满发 kW / 转速 rpm) 上各自的离散分组, 圆面积 ∝ 台数
|
|
|
|
|
+ function capAxis(P, W9) {
|
|
|
|
|
+ const W = 500, H = 158, ML = 54, MR = 34;
|
|
|
|
|
+ const row = (y, spec, label) => { const gs = Object.entries(spec.分组 || {}).map(([k, v]) => ({ v: parseFloat(k), n: v, raw: k })); if (!gs.length) return '';
|
|
|
|
|
+ const lo = Math.min(...gs.map(g => g.v)), hi = Math.max(...gs.map(g => g.v)), pad = (hi - lo) * .22 || 1, x0 = lo - pad, x1 = hi + pad, PX = v => ML + (v - x0) / (x1 - x0) * (W - ML - MR), mn = Math.max(...gs.map(g => g.n)), med = spec['中位'];
|
|
|
|
|
+ return txt(ML - 6, y + 3, label, { fs: 9.5, anchor: 'end' }) + `<line x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}" stroke="var(--line)"/><line x1="${PX(med).toFixed(1)}" y1="${y - 24}" x2="${PX(med).toFixed(1)}" y2="${y + 18}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".55"/>`
|
|
|
|
|
+ + txt(PX(med), y + 28, t('ch.cap.median', { v: med }), { fs: 8.5, anchor: 'middle' })
|
|
|
|
|
+ + gs.map(g => { const r = 5 + Math.sqrt(g.n / mn) * 9; return `<circle cx="${PX(g.v).toFixed(1)}" cy="${y}" r="${r.toFixed(1)}" fill="var(--accent)" fill-opacity=".42" stroke="var(--accent)"/>` + txt(PX(g.v), y - r - 5, esc(g.raw), { anchor: 'middle', fill: 'currentColor' }) + txt(PX(g.v), y + 3.5, g.n, { anchor: 'middle', fill: 'var(--panel)', w: 600 }); }).join(''); };
|
|
|
|
|
+ return svg(W, H, `${row(44, P || {}, t('ch.cap.p'))}${row(120, W9 || {}, t('ch.cap.w'))}${txt(W - MR, 12, t('ch.cap.legend'), { fs: 8.5, anchor: 'end' })}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ // 桨距同档差: 离群台的平均偏 vs 全场同档差中位
|
|
|
|
|
+ function betaBar(b, unitNo) {
|
|
|
|
|
+ const outs = b.离群 || [], med = b['全场同档差中位'] || 0, W = 500, H = 34 + outs.length * 26, ML = 52, MR = 58;
|
|
|
|
|
+ const mx = Math.max(med, ...outs.map(o => o.平均偏)) * 1.18 || 1, PX = v => ML + v / mx * (W - ML - MR);
|
|
|
|
|
+ const rows = outs.map((o, i) => { const y = 30 + i * 26; return txt(ML - 6, y + 10, esc(unitNo(o.t)), { fs: 9.5, anchor: 'end', fill: 'currentColor' }) + `<rect x="${ML}" y="${y + 1}" width="${(PX(o.平均偏) - ML).toFixed(1)}" height="13" rx="2" fill="var(--warning)" fill-opacity=".62"/>` + txt(PX(o.平均偏) + 5, y + 11, o.平均偏 + '°', { fill: 'var(--warning)' }); }).join('');
|
|
|
|
|
+ return svg(W, H, `<line x1="${PX(med).toFixed(1)}" y1="18" x2="${PX(med).toFixed(1)}" y2="${H - 4}" stroke="var(--good)" stroke-dasharray="3 3"/>${txt(PX(med), 12, t('ch.beta.median', { v: med }), { fs: 8.5, fill: 'var(--good)' })}<line x1="${ML}" y1="18" x2="${ML}" y2="${H - 4}" stroke="var(--line)"/>${rows}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ // 尺子巡检: 各轴 极差/σ 对 38 台正态抽样期望 3.7σ
|
|
|
|
|
+ function rulerBar(rl) {
|
|
|
|
|
+ const ks = Object.entries(rl || {}), W = 500, H = 26 + ks.length * 30, ML = 96, MR = 76, NORM = 3.7;
|
|
|
|
|
+ const mxr = Math.max(NORM * 1.35, ...ks.map(([, v]) => v.极差 / v.σ)), PX = v => ML + v / mxr * (W - ML - MR);
|
|
|
|
|
+ const rows = ks.map(([k, v], i) => { const y = 22 + i * 30, ratio = v.极差 / v.σ, wide = ratio > NORM;
|
|
|
|
|
+ return dtxt(ML - 6, y + 11, k, { fs: 9.5, anchor: 'end', fill: 'currentColor' }) + `<rect x="${ML}" y="${y + 2}" width="${(PX(ratio) - ML).toFixed(1)}" height="12" rx="2" fill="${wide ? 'var(--warning)' : 'var(--accent)'}" fill-opacity=".55"/>` + txt(PX(ratio) + 5, y + 11, t('ch.ruler.row', { r: ratio.toFixed(1), s: v.σ })); }).join('');
|
|
|
|
|
+ return svg(W, H, `<line x1="${PX(NORM).toFixed(1)}" y1="14" x2="${PX(NORM).toFixed(1)}" y2="${H - 4}" stroke="var(--good)" stroke-dasharray="3 3"/>${txt(PX(NORM), 10, t('ch.ruler.norm'), { fs: 8.5, fill: 'var(--good)', anchor: 'middle' })}${rows}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ // ── 通用图元 fig(f): 覆盖 /api/turbine · /api/problem 的 5 种 kind ──
|
|
|
|
|
+ // band 本台 vs 全场中位 + 四分位带 · bars 月度条 · xy 两线 · multiline 多线
|
|
|
|
|
+ // vib/vibbar 是经典页的传动链 SVG 与倍中位条, 这里退化为文本 + 表格 (语义不丢)
|
|
|
|
|
+ function fig(f) {
|
|
|
|
|
+ const k = f.kind;
|
|
|
|
|
+ if (k === 'vib') return `<p class="small">${isV(f.text || f.comp) ? `<span class="verbatim">${esc(plain(f.text || f.comp))}</span>` : esc(f.text || f.comp || '')}</p>`;
|
|
|
|
|
+ if (k === 'vibbar') {
|
|
|
|
|
+ const rows = f.rows || []; if (!rows.length) return '';
|
|
|
|
|
+ const sig = v => { const a = Math.abs(v); return a >= 100 ? v.toFixed(0) : a >= 10 ? v.toFixed(1) : a >= 1 ? v.toFixed(2) : a >= .01 ? v.toFixed(3) : Number(v).toExponential(1); };
|
|
|
|
|
+ const byM = {}; rows.forEach(r => { (byM[r.指标] = byM[r.指标] || []).push(r); });
|
|
|
|
|
+ let h = `<p class="small muted">${t('fig.vibbar_note')}</p>`;
|
|
|
|
|
+ for (const [mk, rs] of Object.entries(byM)) {
|
|
|
|
|
+ h += `<div class="tblwrap" style="margin-top:8px"><b class="small muted">${dv(mk)}</b><table style="margin-top:2px"><tr><th>${t('fig.point')}</th><th class="r">${t('fig.x_median')}</th><th class="r">${t('fig.this_unit')}</th><th class="r">${t('fig.fleet_median')}</th><th class="r">z</th><th class="r">${t('fig.trend')}</th></tr>`
|
|
|
|
|
+ + rs.map(r => { const col = Math.abs(r.z) >= 3 ? 'var(--critical)' : Math.abs(r.z) >= 2 ? 'var(--warning)' : 'var(--good)';
|
|
|
|
|
+ return `<tr><td class="small">${dv(r.测点)}</td><td class="r" style="color:${col};font-weight:600">${r.倍中位 != null ? r.倍中位 + '×' : '—'}</td><td class="r small">${sig(r.本台)}</td><td class="r small muted">${sig(r.全场中位)}</td><td class="r small" style="color:${Math.abs(r.z) >= 3 ? 'var(--critical)' : 'var(--muted)'}">${r.z}</td><td class="r small" style="color:${r.六窗比 > 1.15 ? 'var(--warning)' : 'var(--muted)'}">${r.六窗比 != null ? r.六窗比 + '×' : '—'}</td></tr>`; }).join('') + `</table></div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return h;
|
|
|
|
|
+ }
|
|
|
|
|
+ const xs = f.months || f.x || [], n = xs.length; if (!n) return '';
|
|
|
|
|
+ const W = 620, H = 250, ML = 52, MB = 34, MT = 14;
|
|
|
|
|
+ const all = [];
|
|
|
|
|
+ if (k === 'band') [f.self, f.med, f.q1, f.q3].forEach(a => (a || []).forEach(v => { if (v != null && v === v) all.push(v); }));
|
|
|
|
|
+ else if (k === 'bars') { all.push(0); (f.vals || []).forEach(v => { if (v != null && v === v) all.push(v); }); }
|
|
|
|
|
+ else if (k === 'xy') [f.self, f.med].forEach(a => (a || []).forEach(v => { if (v != null && v === v) all.push(v); }));
|
|
|
|
|
+ else (f.series || []).forEach(s => (s.vals || []).forEach(v => { if (v != null && v === v) all.push(v); }));
|
|
|
|
|
+ (f.thresholds || []).forEach(th => { if (th && th.v != null) all.push(th.v); });
|
|
|
|
|
+ if (!all.length) return '';
|
|
|
|
|
+ let lo = Math.min(...all), hi = Math.max(...all); if (lo === hi) { lo -= 1; hi += 1; }
|
|
|
|
|
+ const pad = (hi - lo) * .08; lo -= pad; hi += pad;
|
|
|
|
|
+ const X = i => ML + i / Math.max(n - 1, 1) * (W - ML - 12), Y = v => (H - MB) - (v - lo) / (hi - lo) * (H - MB - MT);
|
|
|
|
|
+ const line = (vals, c, w) => { let segs = [], cur = []; (vals || []).forEach((v, i) => { if (v == null || v !== v) { if (cur.length) segs.push(cur); cur = []; } else cur.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); }); if (cur.length) segs.push(cur);
|
|
|
|
|
+ return segs.map(sg => `<polyline points="${sg.join(' ')}" fill="none" stroke="${c}" stroke-width="${w || 1.8}"/>`).join(''); };
|
|
|
|
|
+ let body = '';
|
|
|
|
|
+ if (k === 'band' && f.q1 && f.q3) { const up = [], dn = [];
|
|
|
|
|
+ f.q3.forEach((v, i) => { if (v != null) up.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); });
|
|
|
|
|
+ for (let i = f.q1.length - 1; i >= 0; i--) if (f.q1[i] != null) dn.push(`${X(i).toFixed(1)},${Y(f.q1[i]).toFixed(1)}`);
|
|
|
|
|
+ if (up.length && dn.length) body += `<polygon points="${up.join(' ')} ${dn.join(' ')}" fill="var(--unknown)" fill-opacity=".18"/>`; }
|
|
|
|
|
+ if (k === 'bars') { const bw = Math.max(3, (W - ML - 20) / n * .6), y0 = Y(Math.max(lo, 0));
|
|
|
|
|
+ body += (f.vals || []).map((v, i) => (v == null || v !== v) ? '' : `<rect x="${(X(i) - bw / 2).toFixed(1)}" y="${Y(v).toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(y0 - Y(v), .8).toFixed(1)}" rx="2" fill="var(--accent)" fill-opacity=".75"/>`).join(''); }
|
|
|
|
|
+ if (f.med) body += line(f.med, 'var(--muted)', 1.7);
|
|
|
|
|
+ if (f.self) body += line(f.self, 'var(--critical)', 2.1) + (f.self || []).map((v, i) => (v == null || v !== v) ? '' : `<circle cx="${X(i).toFixed(1)}" cy="${Y(v).toFixed(1)}" r="2.4" fill="var(--critical)"/>`).join('');
|
|
|
|
|
+ const cols = ['var(--accent)', '#D48A1E', '#2E8B6A', '#7C5295'];
|
|
|
|
|
+ (f.series || []).forEach((s, i) => { body += line(s.vals, cols[i % 4]); });
|
|
|
|
|
+ (f.thresholds || []).forEach(th => { if (th == null || th.v == null) return;
|
|
|
|
|
+ body += `<line x1="${ML}" x2="${W - 12}" y1="${Y(th.v).toFixed(1)}" y2="${Y(th.v).toFixed(1)}" stroke="var(--muted)" stroke-dasharray="5 4"/>` + (th.label ? dtxt(ML + 6, Y(th.v) - 4, th.label, { fs: 10 }) : ''); });
|
|
|
|
|
+ const step = Math.max(1, Math.ceil(n / 8)); let xt = '';
|
|
|
|
|
+ for (let i = 0; i < n; i += step) xt += txt(X(i), H - 10, String(xs[i]).includes('-') ? String(xs[i]).slice(2, 7) : String(xs[i]), { fs: 10, anchor: 'middle' });
|
|
|
|
|
+ let yt = ''; for (let i = 0; i < 5; i++) { const v = lo + (hi - lo) * i / 4;
|
|
|
|
|
+ yt += txt(ML - 6, Y(v) + 3, v.toFixed(Math.abs(hi) < 10 ? 1 : 0), { fs: 10, anchor: 'end' }) + `<line x1="${ML}" x2="${W - 12}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}" stroke="var(--line)"/>`; }
|
|
|
|
|
+ const leg = k === 'band' ? txt(W - 190, 12, t('fig.this_unit'), { fill: 'var(--critical)', fs: 10 }) + txt(W - 110, 12, t('fig.fleet_median'), { fs: 10 }) + txt(W - 40, 12, t('fig.quartile'), { fs: 10 })
|
|
|
|
|
+ : ((f.series || []).length && (f.series || []).length <= 2 ? f.series.map((s, i) => txt(W - 12 - (f.series.length - 1 - i) * 118, 12, '— ' + esc(plain(s.name)), { fill: cols[i % 4], fs: 10, anchor: 'end' })).join('') : '');
|
|
|
|
|
+ const legHtml = ((f.series || []).length > 2) ? `<div class="legend" style="margin-top:6px;flex-wrap:wrap">` + f.series.map((s, i) => `<span><i style="background:${cols[i % 4]}"></i>${isV(s.name) ? `<span class="verbatim">${esc(plain(s.name))}</span>` : esc(s.name)}</span>`).join('') + `</div>` : '';
|
|
|
|
|
+ return svg(W, H, `${yt}${body}${xt}${leg}`) + legHtml;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return { quadChart, rateChart, waterfall, ramChart, attribBar, paretoChart, blameBar, lossName, multiline, capAxis, betaBar, rulerBar, fig };
|
|
|
|
|
+};
|