charts.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. /* 观澜 图元 —— 从经典页 CHART_JS 搬出的 7 个 SVG 图元, 文案全部走语言包。
  2. * 用法: const CH = window.__charts({ t, fmt, esc, dv, EN }); (app.js 里调用, 同一份 ctx)
  3. * 输入形状与经典 /api/fleet 一致 (faults.quad / faults.monthly / control.energy / rel.表 / faults.pareto_n)。
  4. * 设计: 海蓝 = 主, 红 = 需注意, 灰 = 外部/本底; 无动画。
  5. */
  6. window.__charts = function (ctx) {
  7. 'use strict';
  8. const { t, fmt, esc, dv, EN } = ctx;
  9. // ★SVG 里不能用 dv(): 它返回 HTML <span>, SVG 不认这个元素, 原文标注会静默失效 (英文点检实逮)。
  10. // 图元里的数据字符串走**纯文本**通道, 由 SVG 元素自身带 class="verbatim" 承担标注与计数。
  11. const CJK = /[㐀-鿿]/;
  12. const isV = (x) => EN && CJK.test(String(x == null ? '' : x));
  13. const plain = (x) => { x = ctx.displayText ? ctx.displayText(x) : (x == null ? '' : String(x)); if (isV(x) && ctx.verb) ctx.verb(); return x; };
  14. const ttl = (x) => { const label = plain(x); return `<title${isV(label) ? ' class="verbatim"' : ''}>${esc(label)}</title>`; };
  15. 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>`;
  16. 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>`;
  17. // 数据字符串进 SVG 文本: 转义 + (英文模式下含中文则) 标注
  18. const dtxt = (x, y, raw, o = {}) => { const p = plain(raw); return txt(x, y, esc(p), isV(p) ? Object.assign({}, o, { cls: 'verbatim' }) : o); };
  19. const LOSS = { '限电·绑定': 'loss.curtail_bound', '限电命令·未绑定': 'loss.curtail_unbound', '停机(调度令)': 'loss.dispatch', '停机': 'loss.stop', '正常发电': 'loss.normal', '低风待机': 'loss.lowwind' };
  20. const lossName = (k) => LOSS[k] ? t(LOSS[k]) : plain(k);
  21. // 故障四象限: 条数 × 单次中位时长 (对数); 分界 = 条数中位 × 600s (一个 SCADA 采样格, 物理刻度)
  22. function quadChart(items) {
  23. const it = (items || []).filter(x => x && x.n > 0 && x.med > 0);
  24. if (it.length < 3) return `<p class="small muted">${t('ch.quad.few')}</p>`;
  25. const W = 720, H = 330, ML = 58, MR = 16, MB = 44, MT = 16;
  26. const lx = x => Math.log10(x), xs = it.map(d => lx(d.n)), ys = it.map(d => lx(d.med));
  27. const x0 = Math.min(...xs) - .15, x1 = Math.max(...xs) + .15, y0 = Math.min(...ys) - .15, y1 = Math.max(...ys) + .15;
  28. const PX = v => ML + (lx(v) - x0) / (x1 - x0) * (W - ML - MR), PY = v => H - MB - (lx(v) - y0) / (y1 - y0) * (H - MT - MB);
  29. const srt = it.map(d => d.n).sort((a, b) => a - b), nmid = srt[Math.floor(srt.length / 2)];
  30. const cx = PX(nmid), cy = PY(600), mt = Math.max(...it.map(d => d.nt));
  31. 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)';
  32. 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('');
  33. 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('');
  34. const qn = (x, y, a, b) => txt(x, y, a, { fs: 10, op: .85 }) + txt(x, y + 12, b, { fs: 8.5, op: .6 });
  35. const tick = v => v >= 3600 ? (v / 3600) + 'h' : (v >= 60 ? (v / 60) + 'min' : v + 's');
  36. 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('');
  37. 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('');
  38. const mid = MT + (H - MB - MT) / 2;
  39. return svg(W, H, `${yt}${xt}
  40. <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H - MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".5"/>
  41. <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W - MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".5"/>
  42. ${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'))}
  43. ${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'))}
  44. ${pts}${lab}
  45. ${txt(ML + (W - MR - ML) / 2, H - 6, t('ch.quad.xlab'), { fs: 9.5, anchor: 'middle' })}
  46. ${txt(14, mid, t('ch.quad.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 14 ${mid.toFixed(1)})` })}`);
  47. }
  48. // 月度故障率: 柱 = 日均 (可比); 残月灰虚线柱; 标总条数 (原始事实)
  49. function rateChart(mo) {
  50. const ms = mo.months || [], vs = mo.vals || [], cov = mo.cov || ms.map(() => 1), pd = mo.per_day || [];
  51. const W = 720, H = 250, ML = 48, MR = 14, MB = 52, MT = 18, n = ms.length || 1;
  52. const mx = Math.max(...pd.filter(x => x != null), 1), bw = (W - ML - MR) / n;
  53. let bars = '', lab = '';
  54. ms.forEach((m, i) => { const v = pd[i], part = cov[i] < .5; if (v == null) return;
  55. const hh = v / mx * (H - MT - MB), bx = ML + i * bw + bw * .16, by = H - MB - hh, cxm = bx + bw * .34;
  56. 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>`;
  57. lab += txt(cxm, by - 4, v, { anchor: 'middle' }) + txt(cxm, H - MB + 13, t('ch.rate.month', { m: m.slice(5) }), { anchor: 'middle' })
  58. + txt(cxm, H - MB + 25, t('ch.rate.n', { n: vs[i] }), { fs: 8, op: .65, anchor: 'middle' })
  59. + (part ? txt(cxm, H - MB + 37, t('ch.rate.days', { d: Math.round(cov[i] * 30) }), { fs: 8, fill: 'var(--warning)', anchor: 'middle' }) : ''); });
  60. const yt = [0, .5, 1].map(f => txt(ML - 6, H - MB - f * (H - MT - MB) + 3, Math.round(mx * f), { anchor: 'end' })).join('');
  61. const mid = MT + (H - MB - MT) / 2;
  62. 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)"/>
  63. ${bars}${lab}${yt}${txt(14, mid, t('ch.rate.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 14 ${mid.toFixed(1)})` })}`);
  64. }
  65. // 能量瀑布: 理论可发 → 逐段扣损 (按损失降序) → 实际上网
  66. function waterfall(e) {
  67. if (!e || !e.theo) return '';
  68. const W = 720, H = 300, ML = 8, MR = 8, MT = 30, MB = 64, items = (e.items || []).filter(x => x.loss > 0);
  69. const cols = items.length + 2, cw = (W - ML - MR) / cols, sc = (H - MT - MB) / e.theo;
  70. let run = e.theo, h = '', i = 0;
  71. 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}"/>`;
  72. 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' });
  73. 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));
  74. items.forEach(x => { i++; const top = H - MB - run * sc, hh = x.loss * sc; run -= x.loss; const nm = lossName(x.k);
  75. 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"/>`
  76. + lab(i, top, esc(nm.length > 12 ? nm.slice(0, 11) + '…' : nm), '−' + fmt(x.loss), 'var(--critical)')
  77. + ttl(t('ch.wf.tip', { nm, loss: fmt(x.loss), pct: (x.loss / e.theo * 100).toFixed(1), h: fmt(x.h) })); });
  78. 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)');
  79. 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}`);
  80. }
  81. // 可靠性 × 可维修性象限: x = MTBF (对数), y = MDT, 面积 = 累计停机
  82. function ramChart(tb, partName) {
  83. const it = (tb || []).filter(x => x.MTBF_h && x.MDT_h);
  84. if (it.length < 3) return `<p class="small muted">${t('ch.ram.few')}</p>`;
  85. const W = 720, H = 340, ML = 56, MR = 112, MB = 48, MT = 16;
  86. const lg = v => Math.log10(v), xs = it.map(d => lg(d.MTBF_h)), ys = it.map(d => d.MDT_h);
  87. const x0 = Math.min(...xs) - .08, x1 = Math.max(...xs) + .08, y0 = 0, y1 = Math.max(...ys) * 1.12;
  88. const PX = v => ML + (lg(v) - x0) / (x1 - x0) * (W - ML - MR), PY = v => H - MB - (v - y0) / (y1 - y0) * (H - MT - MB);
  89. const md = a => { const z = a.slice().sort((p, q) => p - q); return z[Math.floor(z.length / 2)]; };
  90. 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));
  91. const nm = d => plain(partName ? partName(d.部件) : d.部件);
  92. 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)');
  93. 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>`
  94. + 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('');
  95. 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 });
  96. 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('');
  97. const yt = [0, 5, 10, 15, 20].filter(v => v <= y1).map(v => txt(ML - 6, PY(v) + 3, v, { anchor: 'end' })).join('');
  98. const mid = MT + (H - MB - MT) / 2;
  99. 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)"/>
  100. <line x1="${cx.toFixed(1)}" y1="${MT}" x2="${cx.toFixed(1)}" y2="${H - MB}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".45"/>
  101. <line x1="${ML}" y1="${cy.toFixed(1)}" x2="${W - MR}" y2="${cy.toFixed(1)}" stroke="var(--muted)" stroke-dasharray="3 3" stroke-opacity=".45"/>
  102. ${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'))}
  103. ${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'))}
  104. ${pts}${txt(ML + (W - MR - ML) / 2, H - 8, t('ch.ram.xlab'), { fs: 9.5, anchor: 'middle' })}
  105. ${txt(13, mid, t('ch.ram.ylab'), { fs: 9.5, anchor: 'middle', tr: `rotate(-90 13 ${mid.toFixed(1)})` })}
  106. <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'))}
  107. <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'))}
  108. ${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 })}`);
  109. }
  110. // 归因覆盖条: 已归因 / 未归类 / 无记录
  111. function attribBar(tb, tot) {
  112. const nore = (tb || []).find(x => x.部件 === '无记录停机'), unk = nore ? nore.停机时长h : 0;
  113. const un2 = (tb || []).filter(x => x.部件 === '未归类').reduce((a, b) => a + b.停机时长h, 0), ok = tot - unk - un2, W = 720, H = 54;
  114. 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}"/>`;
  115. const w1 = ok / tot * W, w2 = un2 / tot * W, w3 = unk / tot * W;
  116. return svg(W, H, `${seg(0, w1, 'var(--accent)', .55)}${seg(w1, w2, 'var(--warning)', .5)}${seg(w1 + w2, w3, 'var(--critical)', .5)}
  117. ${txt(4, 10, t('ch.attrib.ok', { h: Math.round(ok), p: (ok / tot * 100).toFixed(0) }))}
  118. ${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' })}
  119. ${txt(w1 + w2 / 2, 50, t('ch.attrib.uncls', { h: Math.round(un2) }), { fill: 'var(--warning)', anchor: 'middle' })}`);
  120. }
  121. // 帕累托: 降序条 + 累计折线 + 80% 线
  122. function paretoChart(items, unit, note) {
  123. const it = (items || []).filter(x => x && x.v > 0);
  124. if (!it.length) return `<p class="small muted">${t('ch.pareto.none')}</p>`;
  125. 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;
  126. const short = k => { k = plain(k); const m = String(k).match(/^([0-9]+)/); return m ? m[1] : String(k).slice(0, 4); };
  127. let cum = 0, bars = '', line = [], dots = '', k80 = null;
  128. it.forEach((x, i) => { const hh = x.v / mx * (H - MT - MB), bx = ML + i * bw + bw * .14, by = H - MB - hh;
  129. 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>`;
  130. cum += x.v; const pct = cum / tot; if (k80 === null && pct >= .8) k80 = i + 1;
  131. const px = ML + i * bw + bw / 2, py = MT + (1 - pct) * (H - MT - MB); line.push(`${px.toFixed(1)},${py.toFixed(1)}`);
  132. 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>`; });
  133. const y80 = MT + .2 * (H - MT - MB);
  134. const xt = it.map((x, i) => dtxt(ML + i * bw + bw / 2, H - MB + 13, short(x.k), { anchor: 'middle' })).join('');
  135. 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('');
  136. 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(' · ');
  137. 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}
  138. <line x1="${ML}" y1="${y80.toFixed(1)}" x2="${W - MR}" y2="${y80.toFixed(1)}" stroke="var(--warning)" stroke-dasharray="4 3" stroke-opacity=".7"/>
  139. ${txt(W - MR - 3, y80 - 4, t('ch.pareto.cum80'), { fill: 'var(--warning)', anchor: 'end' })}
  140. <polyline points="${line.join(' ')}" fill="none" stroke="var(--warning)" stroke-width="1.5"/>${dots}${xt}${yt}
  141. ${txt(ML + (W - MR - ML) / 2, H - 3, t('ch.pareto.xlab'), { fs: 8.5, anchor: 'middle' })}`)
  142. + `<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>`;
  143. }
  144. // 损失责任分账: 交易与调度侧 / 设备侧 / 运行侧
  145. function blameBar(e) {
  146. const cat = k => (k.indexOf('限电') >= 0 || k.indexOf('调度') >= 0) ? 0 : (k === '停机' ? 1 : 2);
  147. 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)'];
  148. const g = [0, 0, 0]; (e.items || []).forEach(x => { g[cat(x.k)] += x.loss; });
  149. const tot = g.reduce((a, b) => a + b, 0) || 1, W = 500, H = 44; let x = 0, seg = '';
  150. g.forEach((v, i) => { const w = v / tot * W;
  151. 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; });
  152. 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('');
  153. return svg(W, H, `${txt(0, 8, t('ch.blame.title', { tot: fmt(Math.round(tot)) }))}${seg}`) + leg;
  154. }
  155. // 多线月度图 (季节性): 至多 4 条序列, 同一 y 轴; 断点不连线
  156. function multiline(f) {
  157. const xs = f.months || f.x || [], n = xs.length; if (!n) return '';
  158. const W = 620, H = 250, ML = 52, MB = 34, cols = ['var(--accent)', '#D48A1E', '#2E8B6A', '#7C5295'];
  159. 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); });
  160. if (!all.length) return '';
  161. 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;
  162. const X = i => ML + i / Math.max(n - 1, 1) * (W - ML - 12), Y = v => (H - MB) - (v - lo) / (hi - lo) * (H - MB - 14);
  163. 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);
  164. return segs.map(sg => `<polyline points="${sg.join(' ')}" fill="none" stroke="${c}" stroke-width="1.8"/>`).join(''); };
  165. let body = (f.series || []).map((s, k) => line(s.vals, cols[k % 4])).join('');
  166. const step = Math.max(1, Math.ceil(n / 8)); let xt = '';
  167. 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' });
  168. 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)"/>`; }
  169. (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 }) : ''); });
  170. if (f.xlab) body += dtxt((ML + W) / 2, H - 1, f.xlab, { fs: 10, anchor: 'middle' });
  171. 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>`;
  172. return svg(W, H, `${yt}${body}${xt}`) + leg;
  173. }
  174. // 封顶参数落位: 两轴 (满发 kW / 转速 rpm) 上各自的离散分组, 圆面积 ∝ 台数
  175. function capAxis(P, W9) {
  176. const W = 500, H = 158, ML = 54, MR = 34;
  177. const row = (y, spec, label) => { const gs = Object.entries(spec.分组 || {}).map(([k, v]) => ({ v: parseFloat(k), n: v, raw: k })); if (!gs.length) return '';
  178. 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['中位'];
  179. 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"/>`
  180. + txt(PX(med), y + 28, t('ch.cap.median', { v: med }), { fs: 8.5, anchor: 'middle' })
  181. + 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(''); };
  182. 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' })}`);
  183. }
  184. // 桨距同档差: 离群台的平均偏 vs 全场同档差中位
  185. function betaBar(b, unitNo) {
  186. const outs = b.离群 || [], med = b['全场同档差中位'] || 0, W = 500, H = 34 + outs.length * 26, ML = 52, MR = 58;
  187. const mx = Math.max(med, ...outs.map(o => o.平均偏)) * 1.18 || 1, PX = v => ML + v / mx * (W - ML - MR);
  188. 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('');
  189. 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}`);
  190. }
  191. // 尺子巡检: 各轴 极差/σ 对 38 台正态抽样期望 3.7σ
  192. function rulerBar(rl) {
  193. const ks = Object.entries(rl || {}), W = 500, H = 26 + ks.length * 30, ML = 96, MR = 76, NORM = 3.7;
  194. const mxr = Math.max(NORM * 1.35, ...ks.map(([, v]) => v.极差 / v.σ)), PX = v => ML + v / mxr * (W - ML - MR);
  195. const rows = ks.map(([k, v], i) => { const y = 22 + i * 30, ratio = v.极差 / v.σ, wide = ratio > NORM;
  196. 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('');
  197. 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}`);
  198. }
  199. // ── 通用图元 fig(f): 覆盖 /api/turbine · /api/problem 的 5 种 kind ──
  200. // band 本台 vs 全场中位 + 四分位带 · bars 月度条 · xy 两线 · multiline 多线
  201. // vib/vibbar 是经典页的传动链 SVG 与倍中位条, 这里退化为文本 + 表格 (语义不丢)
  202. function fig(f) {
  203. const k = f.kind;
  204. 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>`;
  205. if (k === 'vibbar') {
  206. const rows = f.rows || []; if (!rows.length) return '';
  207. 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); };
  208. const byM = {}; rows.forEach(r => { (byM[r.指标] = byM[r.指标] || []).push(r); });
  209. let h = `<p class="small muted">${t('fig.vibbar_note')}</p>`;
  210. for (const [mk, rs] of Object.entries(byM)) {
  211. 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>`
  212. + rs.map(r => { const col = Math.abs(r.z) >= 3 ? 'var(--critical)' : Math.abs(r.z) >= 2 ? 'var(--warning)' : 'var(--good)';
  213. 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>`;
  214. }
  215. return h;
  216. }
  217. const xs = f.months || f.x || [], n = xs.length; if (!n) return '';
  218. const W = 620, H = 250, ML = 52, MB = 34, MT = 14;
  219. const all = [];
  220. if (k === 'band') [f.self, f.med, f.q1, f.q3].forEach(a => (a || []).forEach(v => { if (v != null && v === v) all.push(v); }));
  221. else if (k === 'bars') { all.push(0); (f.vals || []).forEach(v => { if (v != null && v === v) all.push(v); }); }
  222. else if (k === 'xy') [f.self, f.med].forEach(a => (a || []).forEach(v => { if (v != null && v === v) all.push(v); }));
  223. else (f.series || []).forEach(s => (s.vals || []).forEach(v => { if (v != null && v === v) all.push(v); }));
  224. (f.thresholds || []).forEach(th => { if (th && th.v != null) all.push(th.v); });
  225. if (!all.length) return '';
  226. let lo = Math.min(...all), hi = Math.max(...all); if (lo === hi) { lo -= 1; hi += 1; }
  227. const pad = (hi - lo) * .08; lo -= pad; hi += pad;
  228. const X = i => ML + i / Math.max(n - 1, 1) * (W - ML - 12), Y = v => (H - MB) - (v - lo) / (hi - lo) * (H - MB - MT);
  229. 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);
  230. return segs.map(sg => `<polyline points="${sg.join(' ')}" fill="none" stroke="${c}" stroke-width="${w || 1.8}"/>`).join(''); };
  231. let body = '';
  232. if (k === 'band' && f.q1 && f.q3) { const up = [], dn = [];
  233. f.q3.forEach((v, i) => { if (v != null) up.push(`${X(i).toFixed(1)},${Y(v).toFixed(1)}`); });
  234. 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)}`);
  235. if (up.length && dn.length) body += `<polygon points="${up.join(' ')} ${dn.join(' ')}" fill="var(--unknown)" fill-opacity=".18"/>`; }
  236. if (k === 'bars') { const bw = Math.max(3, (W - ML - 20) / n * .6), y0 = Y(Math.max(lo, 0));
  237. 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(''); }
  238. if (f.med) body += line(f.med, 'var(--muted)', 1.7);
  239. 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('');
  240. const cols = ['var(--accent)', '#D48A1E', '#2E8B6A', '#7C5295'];
  241. (f.series || []).forEach((s, i) => { body += line(s.vals, cols[i % 4]); });
  242. (f.thresholds || []).forEach(th => { if (th == null || th.v == null) return;
  243. 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 }) : ''); });
  244. const step = Math.max(1, Math.ceil(n / 8)); let xt = '';
  245. 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' });
  246. let yt = ''; for (let i = 0; i < 5; i++) { const v = lo + (hi - lo) * i / 4;
  247. 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)"/>`; }
  248. 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 })
  249. : ((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('') : '');
  250. 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>` : '';
  251. return svg(W, H, `${yt}${body}${xt}${leg}`) + legHtml;
  252. }
  253. return { quadChart, rateChart, waterfall, ramChart, attribBar, paretoChart, blameBar, lossName, multiline, capAxis, betaBar, rulerBar, fig };
  254. };