| class ResmpBenchmarkChart extends HTMLElement { |
| connectedCallback() { |
| const source = this.querySelector('script[type="application/json"]'); |
| if (source) this._config = JSON.parse(source.textContent); |
| this.render(); |
| } |
| set config(value) { this._config = value; this.render(); } |
| get config() { return this._config; } |
| render() { |
| const cfg = this._config; |
| if (!cfg) return; |
| this.validate(cfg); |
| const all = cfg.groups.flatMap(g => g.panels.flatMap(p => Object.values(p.values))).filter(Number.isFinite); |
| const min = Math.min(0, ...all), max = Math.max(1, ...all); |
| this.innerHTML = `<section class="resmp-chart"> |
| <header class="chart-head"> |
| <div class="chart-nameboard"><span><small>RESMP.DEV / EVALUATION</small>${this.escape(cfg.title)}</span></div> |
| <div class="chart-actions"><button type="button" data-export>EXPORT SVG</button><button type="button" data-print>PRINT</button></div> |
| </header> |
| <p class="chart-subtitle">${this.escape(cfg.subtitle || '')}</p> |
| <div class="chart-legend">${cfg.series.map(s => `<span><i style="--series:${this.escape(s.color)}"></i>${this.escape(s.label)}</span>`).join('')}</div> |
| <div class="chart-groups">${cfg.groups.map((g, gi) => `<section class="chart-group"> |
| <header><span>${String(gi + 1).padStart(2,'0')}</span><h2>${this.escape(g.name)}</h2><small>${cfg.higherIsBetter === false ? 'LOWER IS BETTER' : 'HIGHER IS BETTER'}</small></header> |
| <div class="chart-panels">${g.panels.map(p => this.panel(p, cfg.series, min, max)).join('')}</div> |
| </section>`).join('')}</div> |
| <footer class="chart-footer"><span>RESMP BENCHMARK INSTRUMENT / REV 01</span><span>DIRECT LABELS · FIXED SERIES ORDER · SHARED SCALE</span></footer> |
| </section>`; |
| this.querySelector('[data-export]').addEventListener('click', () => this.exportSvg()); |
| this.querySelector('[data-print]').addEventListener('click', () => window.print()); |
| } |
| panel(panel, series, min, max) { |
| const width = 300, height = 178, left = 18, right = 12, top = 22, bottom = 34; |
| const plotH = height - top - bottom, zeroY = top + (max / (max - min)) * plotH; |
| const gap = 7, barW = Math.min(36, (width-left-right-gap*(series.length-1))/series.length); |
| const total = series.length * barW + (series.length - 1) * gap, start = (width - total) / 2; |
| const bars = series.map((s, i) => { |
| const raw = panel.values[s.id]; |
| if (raw === null || raw === undefined || !Number.isFinite(Number(raw))) { |
| const x = start + i * (barW + gap); |
| return `<g><rect x="${x}" y="${top}" width="${barW}" height="${plotH}" fill="none" stroke="${this.escape(s.color)}" stroke-dasharray="3 3" opacity=".35"/><text x="${x+barW/2}" y="${zeroY-6}" text-anchor="middle" class="value">N/A</text></g>`; |
| } |
| const v = Number(raw), yv = top + ((max - v) / (max - min)) * plotH; |
| const y = Math.min(yv, zeroY), h = Math.max(1, Math.abs(zeroY - yv)), x = start + i * (barW + gap); |
| const labelY = v >= 0 ? y - 5 : y + h + 11; |
| return `<g><rect x="${x}" y="${y}" width="${barW}" height="${h}" fill="${this.escape(s.color)}"/><text x="${x+barW/2}" y="${labelY}" text-anchor="middle" class="value">${v.toFixed(2)}</text><text x="${x+barW/2}" y="${Math.min(y+h-7, zeroY-7)}" text-anchor="middle" class="series-code">${this.escape(s.id.slice(0,2).toUpperCase())}</text></g>`; |
| }).join(''); |
| return `<figure><svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${this.escape(panel.name)} benchmark"><line x1="${left}" y1="${zeroY}" x2="${width-right}" y2="${zeroY}" class="baseline"/>${bars}<text x="${width/2}" y="${height-8}" text-anchor="middle" class="panel-name">${this.escape(panel.name)}</text></svg></figure>`; |
| } |
| validate(cfg) { |
| if (!cfg || typeof cfg !== 'object') throw new TypeError('Benchmark config must be an object.'); |
| if (!Array.isArray(cfg.series) || cfg.series.length < 1) throw new TypeError('Benchmark config requires at least one series.'); |
| if (!Array.isArray(cfg.groups) || cfg.groups.length < 1) throw new TypeError('Benchmark config requires at least one group.'); |
| const ids = cfg.series.map(s => s.id); |
| if (new Set(ids).size !== ids.length) throw new TypeError('Series IDs must be unique.'); |
| for (const group of cfg.groups) { |
| if (!Array.isArray(group.panels) || group.panels.length < 1) throw new TypeError(`Group ${group.name || '(unnamed)'} requires panels.`); |
| } |
| } |
| exportSvg() { |
| const chart = this.querySelector('.resmp-chart').cloneNode(true); chart.querySelector('.chart-actions')?.remove(); |
| const css = [...document.styleSheets].flatMap(s => { try { return [...s.cssRules].map(r=>r.cssText) } catch { return [] } }).join('\n'); |
| const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="${Math.max(900, this.querySelector('.resmp-chart').scrollHeight)}"><foreignObject width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml"><style>${css}</style>${chart.outerHTML}</div></foreignObject></svg>`; |
| const url = URL.createObjectURL(new Blob([svg], {type:'image/svg+xml'})); |
| const a = document.createElement('a'); a.href = url; a.download = 'resmp-benchmark.svg'; a.click(); setTimeout(() => URL.revokeObjectURL(url), 500); |
| } |
| escape(value) { return String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } |
| } |
| customElements.define('resmp-benchmark-chart', ResmpBenchmarkChart); |
|
|