openvibers/openvibe.shared ov-metrics.js
ov-metrics.js
| 1 | /** |
| 2 | * openvibe-shared/ov-metrics.js — the network's one way to show numbers (ai-metrics.md, 2026-11-12): Report, Exchange, |
| 3 | * Compare and the admin panel draw their AI and usage numbers with these, and nobody draws a chart of their own. The |
| 4 | * look is the theme's (tokens only); every piece works at 390 px, in both themes, and with motion off. |
| 5 | * |
| 6 | * OVMetrics.tiles([{ label, value, unit?, sub?, trend?: [n], delta?: 0.12, better?: 'up'|'down', href? }]) |
| 7 | * OVMetrics.spark(values, { label?, width?, height? }) a trend line with its last point |
| 8 | * OVMetrics.bars(rows, { label, value, format?, max?, note? }) a ranked bar list |
| 9 | * OVMetrics.percentiles({ p50, p90?, p95 }, { unit: 'ms'|'tps'|'usd', max?, label? }) p50 and p95 on one scale |
| 10 | * OVMetrics.split([{ label, value, tone? }], { format? }) one bar split into its parts, with a legend |
| 11 | * OVMetrics.table(rows, [{ key, label, format?, numeric?, bar? }], { sort?, caption? }) a sortable table |
| 12 | * OVMetrics.format.{ ms, usd, count, tokens, rate, tps, bytes, delta } the same words everywhere |
| 13 | * |
| 14 | * Builders return DOM elements (no innerHTML of data). In Node only `format` is there (servers write the same words). |
| 15 | */ |
| 16 | (function (root, factory) { |
| 17 | const api = factory(typeof document !== 'undefined' ? document : null); |
| 18 | if (typeof module === 'object' && module.exports) module.exports = api; |
| 19 | else root.OVMetrics = api; |
| 20 | })(typeof globalThis !== 'undefined' ? globalThis : this, function (doc) { |
| 21 | ; |
| 22 | |
| 23 | // ── The words ── |
| 24 | const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v)); |
| 25 | const DASH = '—'; |
| 26 | const format = { |
| 27 | ms(v) { const n = num(v); if (n == null) return DASH; if (n < 1000) return `${Math.round(n)} ms`; if (n < 60000) return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)} s`; return `${Math.floor(n / 60000)} min ${Math.round((n % 60000) / 1000)} s`; }, |
| 28 | usd(v) { const n = num(v); if (n == null) return DASH; if (n === 0) return '$0'; if (Math.abs(n) < 1) { const d = Math.min(6, Math.max(2, 1 - Math.floor(Math.log10(Math.abs(n))))); return `$${n.toFixed(d).replace(/(\.\d\d\d*?)0+$/, '$1')}`; } if (Math.abs(n) < 100) return `$${n.toFixed(2)}`; return `$${Math.round(n).toLocaleString('en-US')}`; }, |
| 29 | count(v) { const n = num(v); if (n == null) return DASH; const a = Math.abs(n); if (a < 1000) return String(Math.round(n)); if (a < 1e6) return `${(n / 1e3).toFixed(a < 1e4 ? 1 : 0)}k`; if (a < 1e9) return `${(n / 1e6).toFixed(a < 1e7 ? 1 : 0)}M`; return `${(n / 1e9).toFixed(1)}B`; }, |
| 30 | tokens(v) { const s = format.count(v); return s === DASH ? s : `${s} tokens`; }, |
| 31 | rate(v) { const n = num(v); if (n == null) return DASH; const p = n * 100; return `${p >= 99.95 || p === 0 ? p.toFixed(0) : p >= 10 ? p.toFixed(1) : p.toFixed(2)}%`; }, |
| 32 | tps(v) { const n = num(v); return n == null ? DASH : `${n >= 100 ? Math.round(n) : n.toFixed(1)} tok/s`; }, |
| 33 | bytes(v) { const n = num(v); if (n == null) return DASH; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let x = n; while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; } return `${x.toFixed(x < 10 && i ? 1 : 0)} ${u[i]}`; }, |
| 34 | delta(v) { const n = num(v); if (n == null) return ''; const p = n * 100; return `${p > 0 ? '+' : ''}${Math.abs(p) < 10 ? p.toFixed(1) : Math.round(p)}%`; }, |
| 35 | }; |
| 36 | if (!doc) return { format }; |
| 37 | |
| 38 | // ── The look: theme tokens only ── |
| 39 | const CSS = [ |
| 40 | '.ovm-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,150px),1fr));gap:10px}', |
| 41 | '.ovm-tile{display:grid;gap:4px;align-content:start;padding:12px 14px;border:1px solid var(--border,#2a3346);border-radius:14px;background:var(--bg-card,transparent);min-width:0;color:inherit;text-decoration:none}', |
| 42 | 'a.ovm-tile:hover,a.ovm-tile:focus-visible{border-color:var(--accent,#5b8cff)}', |
| 43 | '.ovm-label{font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted,#8b96ad)}', |
| 44 | '.ovm-value{font-size:24px;font-weight:700;line-height:1.1;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}', |
| 45 | '.ovm-value small{font-size:13px;font-weight:600;color:var(--text-secondary,#b7c0d3);margin-left:3px}', |
| 46 | '.ovm-sub{font-size:12.5px;color:var(--text-secondary,#b7c0d3);font-variant-numeric:tabular-nums}', |
| 47 | '.ovm-delta{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums}.ovm-delta.is-good{color:var(--success,#22c55e)}.ovm-delta.is-bad{color:var(--danger,#ef4444)}', |
| 48 | '.ovm-spark{display:block;width:100%;height:28px;overflow:visible}.ovm-spark path{fill:none;stroke:var(--accent,#5b8cff);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke}.ovm-spark .ovm-area{fill:var(--accent,#5b8cff);opacity:.12;stroke:none}.ovm-spark circle{fill:var(--accent,#5b8cff)}', |
| 49 | '.ovm-bars{list-style:none;margin:0;padding:0;display:grid;gap:6px}', |
| 50 | '.ovm-bar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;align-items:center;font-size:13.5px}', |
| 51 | '.ovm-bar-l{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ovm-bar-v{font-variant-numeric:tabular-nums;color:var(--text-secondary,#b7c0d3)}', |
| 52 | '.ovm-bar-t{grid-column:1/-1;height:6px;border-radius:999px;background:var(--bg-tertiary,rgba(127,127,127,.15));overflow:hidden}.ovm-bar-t>span{display:block;height:100%;border-radius:inherit;background:var(--accent,#5b8cff)}', |
| 53 | '.ovm-bar small{grid-column:1/-1;color:var(--text-muted,#8b96ad);font-size:12px}', |
| 54 | '.ovm-pct{display:grid;gap:6px;min-width:0}.ovm-pct-track{position:relative;height:10px;border-radius:999px;background:var(--bg-tertiary,rgba(127,127,127,.15))}', |
| 55 | '.ovm-pct-range{position:absolute;top:0;bottom:0;border-radius:999px;background:color-mix(in srgb,var(--accent,#5b8cff) 35%,transparent)}', |
| 56 | '.ovm-pct-mark{position:absolute;top:-3px;width:3px;height:16px;border-radius:2px;background:var(--accent,#5b8cff);transform:translateX(-50%)}.ovm-pct-mark.is-tail{background:var(--warning,#f59e0b)}', |
| 57 | '.ovm-pct-keys{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12.5px;color:var(--text-secondary,#b7c0d3);font-variant-numeric:tabular-nums}.ovm-pct-keys b{color:var(--text,inherit)}', |
| 58 | '.ovm-split{display:grid;gap:8px}.ovm-split-bar{display:flex;height:12px;border-radius:999px;overflow:hidden;background:var(--bg-tertiary,rgba(127,127,127,.15))}.ovm-split-bar>span{height:100%}', |
| 59 | '.ovm-split-legend{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12.5px;color:var(--text-secondary,#b7c0d3)}.ovm-split-legend i{display:inline-block;width:9px;height:9px;border-radius:3px;margin-right:6px;vertical-align:0}', |
| 60 | '.ovm-t1{background:var(--accent,#5b8cff)}.ovm-t2{background:var(--success,#22c55e)}.ovm-t3{background:var(--warning,#f59e0b)}.ovm-t4{background:var(--text-muted,#8b96ad)}.ovm-t5{background:var(--danger,#ef4444)}', |
| 61 | '.ovm-table-wrap{overflow-x:auto;border:1px solid var(--border,#2a3346);border-radius:12px;container:ovm-table/inline-size}', |
| 62 | '.ovm-table{border-collapse:collapse;width:100%;font-size:13.5px}.ovm-table caption{text-align:left;padding:10px 12px;font-weight:600}', |
| 63 | '.ovm-table th,.ovm-table td{padding:8px 12px;border-bottom:1px solid var(--border,#2a3346);text-align:left;white-space:nowrap}.ovm-table tr:last-child td{border-bottom:0}', |
| 64 | '.ovm-table th{font-size:12px;font-weight:600;color:var(--text-muted,#8b96ad)}.ovm-table th button{all:unset;cursor:pointer;display:inline-flex;gap:4px;align-items:center}.ovm-table th button:focus-visible{outline:2px solid var(--accent,#5b8cff);outline-offset:2px;border-radius:4px}', |
| 65 | '.ovm-table td.is-num,.ovm-table th.is-num{text-align:right;font-variant-numeric:tabular-nums}.ovm-table th.is-num button{flex-direction:row-reverse}', |
| 66 | // A narrow box (a phone, a side column): each row is a card, its name on top and each value under its label; |
| 67 | // the heads become sort buttons. The roles keep it a table for a screen reader whatever its display. This |
| 68 | // block comes after the base table rules, so it wins the cascade at equal specificity. |
| 69 | '@container ovm-table (max-width:520px){.ovm-table,.ovm-table tbody,.ovm-table thead,.ovm-table td{display:block}' |
| 70 | + '.ovm-table caption{display:block;white-space:normal}.ovm-table thead tr{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;border-bottom:1px solid var(--border,#2a3346)}' |
| 71 | + '.ovm-table thead th{display:block;border:0;padding:0}.ovm-table th button{min-height:36px;padding:0 10px;box-sizing:border-box;border:1px solid var(--border,#2a3346);border-radius:999px}' |
| 72 | + '.ovm-table th[aria-sort=ascending] button,.ovm-table th[aria-sort=descending] button{border-color:var(--accent,#5b8cff);color:var(--text-primary,#e6edf7)}' |
| 73 | + '.ovm-table th.is-num button{flex-direction:row}' |
| 74 | + '.ovm-table tbody tr{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 16px;padding:10px 12px;border-bottom:1px solid var(--border,#2a3346)}.ovm-table tbody tr:last-child{border-bottom:0}' |
| 75 | + '.ovm-table td,.ovm-table td.is-num{border:0;padding:0;white-space:normal;text-align:left;min-width:0;overflow-wrap:anywhere}' |
| 76 | + '.ovm-table td:first-child{grid-column:1/-1;font-weight:600}' |
| 77 | + '.ovm-table td:not(:first-child)::before{content:attr(data-label);display:block;font-size:11.5px;font-weight:400;color:var(--text-muted,#8b96ad)}' |
| 78 | + '.ovm-table td .ovm-cellbar{display:block;width:100%;margin:4px 0 0}}', |
| 79 | '.ovm-cellbar{display:inline-block;width:48px;height:5px;margin-left:8px;border-radius:999px;background:var(--bg-tertiary,rgba(127,127,127,.15));vertical-align:middle;overflow:hidden}.ovm-cellbar>span{display:block;height:100%;background:var(--accent,#5b8cff)}', |
| 80 | '@media (prefers-reduced-motion:no-preference){html:not([data-ov-motion=off]) .ovm-bar-t>span,html:not([data-ov-motion=off]) .ovm-cellbar>span{transition:width .35s ease}}', |
| 81 | ].join(''); |
| 82 | function style() { |
| 83 | if (doc.getElementById('ov-metrics-css')) return; |
| 84 | const s = doc.createElement('style'); s.id = 'ov-metrics-css'; s.textContent = CSS; (doc.head || doc.documentElement).appendChild(s); |
| 85 | } |
| 86 | function el(tag, cls, kids, attrs) { |
| 87 | const n = doc.createElement(tag); |
| 88 | if (cls) n.className = cls; |
| 89 | for (const k in attrs || {}) if (attrs[k] != null) n.setAttribute(k, attrs[k]); |
| 90 | (kids || []).forEach((c) => { if (c != null && c !== false) n.append(c); }); |
| 91 | return n; |
| 92 | } |
| 93 | const SVG = 'http://www.w3.org/2000/svg'; |
| 94 | const svgEl = (tag, attrs) => { const n = doc.createElementNS(SVG, tag); for (const k in attrs) n.setAttribute(k, attrs[k]); return n; }; |
| 95 | |
| 96 | function spark(values, o = {}) { |
| 97 | style(); |
| 98 | const v = (Array.isArray(values) ? values : []).map(num).filter((x) => x != null); |
| 99 | const w = o.width || 120; const h = o.height || 28; |
| 100 | const svg = svgEl('svg', { class: 'ovm-spark', viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: 'none', role: 'img', 'aria-label': o.label || (v.length ? `Trend: from ${v[0]} to ${v[v.length - 1]}` : 'No trend yet') }); |
| 101 | if (v.length < 2) return svg; |
| 102 | const lo = Math.min(...v); const hi = Math.max(...v); const span = hi - lo || 1; |
| 103 | const pts = v.map((x, i) => [(i / (v.length - 1)) * (w - 4) + 2, h - 3 - ((x - lo) / span) * (h - 6)]); |
| 104 | const d = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' '); |
| 105 | svg.append(svgEl('path', { class: 'ovm-area', d: `${d} L${pts[pts.length - 1][0].toFixed(1)} ${h} L${pts[0][0].toFixed(1)} ${h} Z` }), svgEl('path', { d }), svgEl('circle', { cx: pts[pts.length - 1][0].toFixed(1), cy: pts[pts.length - 1][1].toFixed(1), r: '2.5' })); |
| 106 | return svg; |
| 107 | } |
| 108 | |
| 109 | function tiles(items) { |
| 110 | style(); |
| 111 | const box = el('div', 'ovm-tiles', [], { role: 'list' }); |
| 112 | for (const it of Array.isArray(items) ? items : []) { |
| 113 | const value = el('span', 'ovm-value', [String(it.value == null ? DASH : it.value), it.unit ? el('small', '', [it.unit]) : null]); |
| 114 | const kids = [el('span', 'ovm-label', [it.label]), value]; |
| 115 | if (it.delta != null && Number.isFinite(Number(it.delta))) { |
| 116 | const good = (Number(it.delta) >= 0) === ((it.better || 'up') === 'up'); |
| 117 | kids.push(el('span', `ovm-delta ${Number(it.delta) === 0 ? '' : good ? 'is-good' : 'is-bad'}`, [format.delta(it.delta)], { title: 'Against the period before' })); |
| 118 | } |
| 119 | if (it.sub) kids.push(el('span', 'ovm-sub', [it.sub])); |
| 120 | if (Array.isArray(it.trend) && it.trend.length > 1) kids.push(spark(it.trend, { label: `${it.label}: trend` })); |
| 121 | box.append(el(it.href ? 'a' : 'div', 'ovm-tile', kids, { role: 'listitem', href: it.href || null })); |
| 122 | } |
| 123 | return box; |
| 124 | } |
| 125 | |
| 126 | function bars(rows, o = {}) { |
| 127 | style(); |
| 128 | const list = (Array.isArray(rows) ? rows : []).filter(Boolean); |
| 129 | const val = (r) => num(typeof o.value === 'function' ? o.value(r) : r[o.value || 'value']) || 0; |
| 130 | const lab = (r) => String(typeof o.label === 'function' ? o.label(r) : r[o.label || 'label']); |
| 131 | const fmt = o.format || ((x) => format.count(x)); |
| 132 | const max = num(o.max) || Math.max(1e-12, ...list.map(val)); |
| 133 | const ul = el('ul', 'ovm-bars'); |
| 134 | for (const r of [...list].sort((a, b) => val(b) - val(a))) { |
| 135 | const pct = Math.max(0, Math.min(100, (val(r) / max) * 100)); |
| 136 | ul.append(el('li', 'ovm-bar', [el('span', 'ovm-bar-l', [lab(r)], { title: lab(r) }), el('span', 'ovm-bar-v', [fmt(val(r), r)]), el('span', 'ovm-bar-t', [el('span', '', [], { style: `width:${pct.toFixed(1)}%` })], { 'aria-hidden': 'true' }), o.note ? el('small', '', [o.note(r)]) : null])); |
| 137 | } |
| 138 | return ul; |
| 139 | } |
| 140 | |
| 141 | function percentiles(p, o = {}) { |
| 142 | style(); |
| 143 | const f = o.unit === 'tps' ? format.tps : o.unit === 'usd' ? format.usd : format.ms; |
| 144 | const p50 = num(p && p.p50); const p90 = num(p && p.p90); const p95 = num(p && p.p95); |
| 145 | const max = num(o.max) || Math.max(1e-9, p95 || 0, p90 || 0, p50 || 0) * 1.15; |
| 146 | const at = (x) => `${Math.max(0, Math.min(100, (x / max) * 100)).toFixed(1)}%`; |
| 147 | const track = el('div', 'ovm-pct-track', [], { 'aria-hidden': 'true' }); |
| 148 | if (p50 != null && p95 != null) track.append(el('span', 'ovm-pct-range', [], { style: `left:${at(Math.min(p50, p95))};width:calc(${at(Math.abs(p95 - p50))})` })); |
| 149 | if (p50 != null) track.append(el('span', 'ovm-pct-mark', [], { style: `left:${at(p50)}` })); |
| 150 | if (p95 != null) track.append(el('span', 'ovm-pct-mark is-tail', [], { style: `left:${at(p95)}` })); |
| 151 | const keys = el('p', 'ovm-pct-keys', [el('span', '', ['p50 ', el('b', '', [f(p50)])]), p90 != null ? el('span', '', ['p90 ', el('b', '', [f(p90)])]) : null, el('span', '', ['p95 ', el('b', '', [f(p95)])]), p && p.n != null ? el('span', '', [`${format.count(p.n)} samples`]) : null]); |
| 152 | const box = el('div', 'ovm-pct', [o.label ? el('span', 'ovm-label', [o.label]) : null, track, keys], { role: 'group', 'aria-label': `${o.label || 'Distribution'}: median ${f(p50)}, 95th percentile ${f(p95)}` }); |
| 153 | return box; |
| 154 | } |
| 155 | |
| 156 | function split(parts, o = {}) { |
| 157 | style(); |
| 158 | const list = (Array.isArray(parts) ? parts : []).filter((x) => x && num(x.value) > 0); |
| 159 | const total = list.reduce((s, x) => s + num(x.value), 0) || 1; |
| 160 | const fmt = o.format || format.count; |
| 161 | const bar = el('div', 'ovm-split-bar', [], { 'aria-hidden': 'true' }); |
| 162 | const legend = el('ul', 'ovm-split-legend'); |
| 163 | list.forEach((x, i) => { |
| 164 | const tone = `ovm-t${x.tone || (i % 5) + 1}`; |
| 165 | bar.append(el('span', tone, [], { style: `width:${((num(x.value) / total) * 100).toFixed(1)}%`, title: `${x.label}: ${fmt(x.value)}` })); |
| 166 | legend.append(el('li', '', [el('i', tone), `${x.label} ${fmt(x.value)} (${format.rate(num(x.value) / total)})`])); |
| 167 | }); |
| 168 | return el('div', 'ovm-split', [bar, legend], { role: 'group', 'aria-label': o.label || 'Split' }); |
| 169 | } |
| 170 | |
| 171 | function table(rows, columns, o = {}) { |
| 172 | style(); |
| 173 | const data = (Array.isArray(rows) ? rows : []).slice(); |
| 174 | const cols = Array.isArray(columns) ? columns : []; |
| 175 | let sortKey = o.sort && o.sort.key ? o.sort.key : null; |
| 176 | let dir = o.sort && o.sort.dir === 'asc' ? 1 : -1; |
| 177 | const maxOf = {}; |
| 178 | for (const c of cols) if (c.bar) maxOf[c.key] = Math.max(1e-12, ...data.map((r) => num(r[c.key]) || 0)); |
| 179 | const t = el('table', 'ovm-table', [o.caption ? el('caption', '', [o.caption]) : null], { role: 'table' }); |
| 180 | const head = el('tr', '', [], { role: 'row' }); |
| 181 | const tbody = el('tbody', '', [], { role: 'rowgroup' }); |
| 182 | function drawBody() { |
| 183 | const sorted = sortKey ? [...data].sort((a, b) => { const x = a[sortKey]; const y = b[sortKey]; const nx = num(x); const ny = num(y); return (nx != null && ny != null ? nx - ny : String(x == null ? '' : x).localeCompare(String(y == null ? '' : y))) * dir; }) : data; |
| 184 | tbody.replaceChildren(...sorted.map((r) => el('tr', '', cols.map((c) => { |
| 185 | const v = r[c.key]; |
| 186 | const text = c.format ? c.format(v, r) : (v == null ? DASH : String(v)); |
| 187 | const kids = [text]; |
| 188 | if (c.bar && num(v) != null) kids.push(el('span', 'ovm-cellbar', [el('span', '', [], { style: `width:${((num(v) / maxOf[c.key]) * 100).toFixed(1)}%` })], { 'aria-hidden': 'true' })); |
| 189 | return el('td', c.numeric ? 'is-num' : '', kids, { role: 'cell', 'data-label': c.label }); |
| 190 | }), { role: 'row' }))); |
| 191 | head.querySelectorAll('th').forEach((th) => th.setAttribute('aria-sort', th.dataset.key === sortKey ? (dir === 1 ? 'ascending' : 'descending') : 'none')); |
| 192 | } |
| 193 | for (const c of cols) { |
| 194 | const sortIc = el('span', 'ovm-sort', [], { 'aria-hidden': 'true' }); sortIc.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="14" height="14" style="vertical-align:-2px"><path d="M3.5 5.8h9.2M3.5 12h6.2M3.5 18.2h3M17.5 4.8v14.4M14.3 16.2l3 3l3-3"/></svg>'; |
| 195 | const b = el('button', '', [c.label, sortIc], { type: 'button' }); |
| 196 | b.addEventListener('click', () => { if (sortKey === c.key) dir = -dir; else { sortKey = c.key; dir = c.numeric ? -1 : 1; } drawBody(); }); |
| 197 | const th = el('th', c.numeric ? 'is-num' : '', [b], { scope: 'col', role: 'columnheader', 'data-key': c.key }); |
| 198 | head.append(th); |
| 199 | } |
| 200 | t.append(el('thead', '', [head], { role: 'rowgroup' }), tbody); |
| 201 | drawBody(); |
| 202 | return el('div', 'ovm-table-wrap', [t]); |
| 203 | } |
| 204 | |
| 205 | return { format, tiles, spark, bars, percentiles, split, table, CSS }; |
| 206 | }); |
| 207 |